Skip to content

data.support

Data Support Module

Provides supporting functions for configuring problem instances and generating AFSC-related visualization and qualification metadata in the AFCCP system.

This module contains helper functions used to initialize and update key parameter sets related to AFSCs, cadet eligibility, solution comparison, and qualification tiers. These functions support both the modeling pipeline and result interpretation by preparing model-specific parameters and simplifying downstream visualization or analysis tasks.

Functions

  • initialize_instance_functional_parameters: Adds AFCCP-specific keys to the model parameters, including default display and export options.
  • determine_afsc_plot_details: Sets visualization attributes for each AFSC (e.g., colors, abbreviations, names) used in plots and diagrams.
  • determine_afscs_in_image: Filters AFSCs to display in charts based on the solution scope, accession source, or eligibility threshold.
  • pick_most_changed_afscs: Identifies the AFSCs with the greatest variability in cadet assignments across multiple solutions.
  • cip_to_qual_tiers: Computes cadet qualification tiers (e.g., M1, D2, P3) based on their CIP degree codes for each AFSC.

Typical Use Cases

  • Automatically setting up model parameters based on the data inputs (initialize_instance_functional_parameters)
  • Preparing AFSC visuals for comparison charts or preference graphs (determine_afsc_plot_details, determine_afscs_in_image)
  • Analyzing how different modeling approaches affect AFSC-level cadet outcomes (pick_most_changed_afscs)
  • Generating qualification matrices for cadets using AFOCD-based tiering rules (cip_to_qual_tiers)

initialize_instance_functional_parameters(N)

Initializes the functional parameters used by the CadetCareerProblem object.

Parameters

N : int Number of cadets in the problem instance. Used to scale certain algorithm parameters.

Returns

dict A dictionary of instance parameters (mdl_p) controlling behavior, algorithms, chart rendering, Pyomo integration, CASTLE compatibility, and more.

Overview

This function provides a centralized configuration for the CadetCareerProblem object. Parameters are grouped by functionality and define the default settings for:

  • Generic Solution Handling: Toggles for storing, naming, and gathering metrics from solutions.
  • Matching Algorithm Parameters: Controls for deterministic/rated/genetic matching algorithms.
  • Rated Matching Parameters: Defines logic for cross-commissioning and board behavior for rated tracks.
  • Genetic Algorithm Settings: Population size, mutation logic, crossover mechanics, and GA heuristics.
  • Pyomo Integration: Solver-specific options, time limits, and accessions logic.
  • Constraint Logic: Bounds and options for special constraints (e.g., USSF-specific, merit floors).
  • VFT and Constraint Placement: Value Function Tool (VFT) control and constraint modeling logic.
  • Chart Parameters: Configuration for Bubble, AFSC, utility, and comparison charts.
  • Sensitivity Analysis: Controls for PGL iteration studies and Pareto frontier evaluations.
  • CASTLE Integration: Optional toggles for syncing with the CASTLE system (e.g., GUO compatibility).
  • Chart Color Palettes: A full color dictionary for use across demographics, performance, and visuals.
  • Animation and Interaction Colors: Highlights for matched/unmatched cadets and choice levels.
  • Slide Export and Layout: Coordinates and chart choices for building slides or figures.

Notes

  • Analysts can modify these values in-place or pass an updated version of this dictionary into CadetCareerProblem methods. See the p_dict parameter option of the CadetCareerProblem class
  • Chart rendering behavior (figures, legends, annotations) is also fully parameterized here.
  • Default values are tuned for USAFA cadet datasets but can be adapted to ROTC/OTS or simulation needs.

See Also

Source code in afccp/data/support.py
 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
def initialize_instance_functional_parameters(N):
    """
    Initializes the functional parameters used by the CadetCareerProblem object.

    Parameters
    ----------
    N : int
        Number of cadets in the problem instance. Used to scale certain algorithm parameters.

    Returns
    -------
    dict
        A dictionary of instance parameters (`mdl_p`) controlling behavior, algorithms, chart rendering,
        Pyomo integration, CASTLE compatibility, and more.

    Overview
    --------
    This function provides a centralized configuration for the CadetCareerProblem object.
    Parameters are grouped by functionality and define the default settings for:

    - **Generic Solution Handling**: Toggles for storing, naming, and gathering metrics from solutions.
    - **Matching Algorithm Parameters**: Controls for deterministic/rated/genetic matching algorithms.
    - **Rated Matching Parameters**: Defines logic for cross-commissioning and board behavior for rated tracks.
    - **Genetic Algorithm Settings**: Population size, mutation logic, crossover mechanics, and GA heuristics.
    - **Pyomo Integration**: Solver-specific options, time limits, and accessions logic.
    - **Constraint Logic**: Bounds and options for special constraints (e.g., USSF-specific, merit floors).
    - **VFT and Constraint Placement**: Value Function Tool (VFT) control and constraint modeling logic.
    - **Chart Parameters**: Configuration for Bubble, AFSC, utility, and comparison charts.
    - **Sensitivity Analysis**: Controls for PGL iteration studies and Pareto frontier evaluations.
    - **CASTLE Integration**: Optional toggles for syncing with the CASTLE system (e.g., GUO compatibility).
    - **Chart Color Palettes**: A full color dictionary for use across demographics, performance, and visuals.
    - **Animation and Interaction Colors**: Highlights for matched/unmatched cadets and choice levels.
    - **Slide Export and Layout**: Coordinates and chart choices for building slides or figures.

    Notes
    -----
    - Analysts can modify these values in-place or pass an updated version of this dictionary into
      CadetCareerProblem methods. See the `p_dict` parameter option of the `CadetCareerProblem` class
    - Chart rendering behavior (figures, legends, annotations) is also fully parameterized here.
    - Default values are tuned for USAFA cadet datasets but can be adapted to ROTC/OTS or simulation needs.

    See Also
    --------
    - [CadetCareerProblem](../../../afccp/reference/main/cadetcareerproblem_overview/)
    """

    # Parameters for the graphs
    mdl_p = {

        # Generic Solution Handling (for multiple algorithms/models)
        "initial_solutions": None, "solution_names": None, "add_to_dict": True, "set_to_instance": True,
        "initialize_new_heuristics": False, 'gather_all_metrics': True, 're-calculate x': True, 'solution_method': None,

        # Matching Algorithm Parameters
        'ma_printing': False, 'capacity_parameter': 'quota_max', 'collect_solution_iterations': True,
        'create_new_rated_solutions': True,

        # Rated Matching Algorithm Parameters
        'rated_alternate_afscs': None, 'rated_alternates': True, 'rotc_rated_board_afsc_order': None, 'soc': 'usafa',
        'incorporate_rated_results': True, 'usafa_soc_pilot_cross_in': False, 'socs_to_use': None,

        # Genetic Matching Algorithm Parameters
        "gma_pop_size": 4, 'gma_max_time': 20, 'gma_num_crossover_points': 2, 'gma_mutations': 1,
        'gma_mutation_rate': 1, 'gma_printing': False, 'stopping_conditions': 'Time', 'gma_num_generations': 200,

        # Genetic Algorithm Parameters
        "pop_size": 12, "ga_max_time": 60, "num_crossover_points": 3, "initialize": True, "ga_printing": True,
        "mutation_rate": 0.05, "num_time_points": 100, "num_mutations": int(np.ceil(N / 75)), "time_eval": False,
        "percent_step": 10, "ga_constrain_first_only": False, 'mutation_function': 'cadet_choice',
        'preference_mutation_rate': 0.5,

        # Pyomo General Parameters
        "real_usafa_n": 960, "solver_name": "cbc", "pyomo_max_time": None, "provide_executable": False,
        "executable": None, "exe_extension": False, 'alternate_list_iterations_printing': False,
        'ots_accessions': None, 'ots_selected_preferences_only': True, 'ots_constrain_must_match': False,
        'pyomo_tee': False,

        # Additional Constraints/Modeling
        "assignment_model_obj": "Global Utility", 'ussf_merit_bound': 0.03, 'ussf_soc_pgl_constraint': False,
        'ussf_soc_pgl_constraint_bound': 0.01, 'USSF OM': False,
        'USAFA-Constrained AFSCs': None,

        # Base/Training Parameters
        'BIG M': 100, 'solve_extra_components': False,

        # VFT Model Parameters
        "pyomo_constraint_based": True, "constraint_tolerance": 0.95, "warm_start": None, "init_from_X": False,
        "obtain_warm_start_variables": True, "add_breakpoints": True, "approximate": True,

        # VFT Population Generation Parameters
        "populate": True, "iterate_from_quota": True, "max_quota_iterations": 5, "population_additions": 10,
        "population_generation_model": "Assignment",

        # Model Constraint Placement Algorithm parameters
        'constraint_model_to_use': 'Assignment', "skip_quota_constraint": False,

        # Sensitivity Analysis
        "pareto_step": 10, "num_pgl_analysis_iterations": 30, "import_pgl_analysis_folder": None,

        # Goal Programming Parameters
        "get_reward": False, "con_term": None, "get_new_rewards_penalties": False, "use_gp_df": True,

        # CASTLE Integration Parameters
        "w^G": 0.8,  # Weight on GUO solution relative to CASTLE
        "solve_castle_guo": False,  # Whether we should solve the castle version of GUO or not

        # Value Parameter Generation
        "new_vp_weight": 100, "num_breakpoints": 24,

        # BubbleChart Parameters
        'b_figsize': (13.33, 7.5), 's': 1, 'fw': 100, 'circle_radius_percent': 0.8,
        'fh_ratio': 0.5, 'bw^t_ratio': 0.05, 'bw^l_ratio': 0, 'bw^r_ratio': 0, 'b_title': None,
        'bw^b_ratio': 0, 'bw^u_ratio': 0.02, 'abw^lr_ratio': 0.01, 'abw^ud_ratio': 0.02, 'b_title_size': 30,
        'lh_ratio': 0.1, 'lw_ratio': 0.1, 'dpi': 200, 'pgl_linestyle': '-', 'pgl_color': 'gray',
        'pgl_alpha': 0.5, 'surplus_linestyle': '--', 'surplus_color': 'white', 'surplus_alpha': 1,
        'usafa_pgl_color': 'blue', 'rotc_pgl_color': 'red', 'usafa_bubble': 'blue', 'rotc_bubble': 'red',
        'ots_pgl_color': 'yellow', 'ots_bubble': 'yellow',
        'cb_edgecolor': 'black', 'save_board_default': True, 'circle_color': 'black', 'focus': 'Cadet Choice',
        'save_iteration_frames': True, 'afsc_title_size': 20, 'afsc_names_sized_box': False,
        'b_solver_name': 'couenne', 'b_pyomo_max_time': None, 'row_constraint': False, 'n^rows': 3,
        'simplified_model': True, 'use_pyomo_model': True, 'sort_cadets_by': 'AFSC Preferences', 'add_legend': False,
        'draw_containers': False, 'figure_color': 'black', 'text_color': 'white', 'afsc_text_to_show': 'Norm Score',
        'use_rainbow_hex': True, 'build_orientation_slides': True, 'b_legend': True, 'b_legend_size': 20,
        'b_legend_marker_size': 20, 'b_legend_title_size': 20, 'x_ext_left': 0, 'x_ext_right': 0, 'y_ext_left': 0,
        'y_ext_right': 0, 'show_rank_text': False, 'rank_text_color': 'white', 'fontsize_single_digit_adj': 0.6,
        'b_legend_loc': 'upper right', 'redistribute_x': True, 'cadets_solved_for': None, "y_val_to_pin": 0.03,
        'show_white_surplus_boxes': False,

        # These parameters pertain to the AFSCs that will ultimately show up in the visualizations
        'afscs_solved_for': 'All', 'afscs_to_show': 'All',

        # Generic Chart Handling
        "save": True, "figsize": (19, 10), "facecolor": "white", "title": None, "filename": None, "display_title": True,
        "label_size": 25, "afsc_tick_size": 20, "yaxis_tick_size": 25, "bar_text_size": 15, "xaxis_tick_size": 20,
        "afsc_rotation": None, "bar_color": "#3287cd", "alpha": 1, "legend_size": 25, "title_size": 25,
        "text_size": 15, 'text_bar_threshold': 400, 'dot_size': 35, 'legend_dot_size': 15, 'ncol': 1,
        "color_afsc_text_by_grp": True, "proportion_legend_size": 15, 'proportion_text_bar_threshold': 10,
        "square_figsize": (11, 10), 'legend_fontsize': 15, 'bar_text_offset': None,

        # AFSC Chart Elements
        "eligibility": True, "eligibility_limit": None, "skip_afscs": None, "all_afscs": True, "y_max": 1.1,
        "y_exact_max": None, "preference_chart": False, "preference_proportion": False, "dot_chart": False,
        "sort_by_pgl": True, "solution_in_title": True, "afsc": None, "only_desired_graphs": True,
        'add_legend_afsc_chart': True, 'legend_loc': 'upper right', "add_bound_lines": False,
        "large_afsc_distinction": False,

        # Cadet Utility Chart Elements
        "cadet": 0, "util_type": "Final Utility",

        # Accessions Chart Elements
        "label_size_acc": 25, "acc_text_size": 25, "acc_bar_text_size": 25, "acc_legend_size": 15,
        "acc_text_bar_threshold": 10,

        # Macro Chart Controls
        "cadets_graph": True, "data_graph": "AFOCD Data", "results_graph": "Measure", "objective": "Merit",
        "version": "bar", "macro_chart_kind": "AFSC Chart",

        # Similarity Chart Elements
        "sim_dot_size": 220, "new_similarity_matrix": True, 'default_sim_color': 'black',
        'default_sim_marker': 'o',

        # Value Function Chart Elements
        "smooth_value_function": False,

        # Solution Comparison Chart Information
        "compare_solutions": False, "vp_name": None,
        "color_choices": ["red", "blue", "green", "orange", "purple", "black", "magenta"],
        "marker_choices": ['o', 'D', '^', 'P', 'v', '*', 'h'], "marker_size": 20, "comparison_afscs": None,
        "zorder_choices": [2, 3, 2, 2, 2, 2, 2], "num_solutions": None,

        # Multi-Criteria Chart
        "num_afscs_to_compare": 8, "comparison_criteria": ["Utility", "Merit", "AFOCD"],

        # Slide Parameters
        "ch_top": 2.35, "ch_left": 0.59, "ch_height": 4.64, "ch_width": 8.82,

        # Subset of charts I actually really care about
        "desired_charts": [("Combined Quota", "quantity_bar"),
                           ("Norm Score", "quantity_bar_proportion"),
                           ("Norm Score", "bar"),
                           ("Norm Score", "quantity_bar_choice"),
                           ("Utility", "quantity_bar_proportion"),
                           ("Utility", "quantity_bar_choice"),
                           ("Merit", "bar"),
                           ("USAFA Proportion", "quantity_bar_proportion"),
                           ("USAFA Proportion", "preference_chart"),
                           ('Extra', 'SOC Chart'),
                           ('Extra', 'SOC Chart_proportion')],

        "desired_comparison_charts": [('Utility', 'median_preference'), ('Combined Quota', 'dot'), ('Utility', 'dot'),
                                      ('Norm Score', 'dot'), ('Merit', 'dot'), ('Tier 1', 'dot'),
                                      # ('Extra', 'Race Chart'),
                                      ('USAFA Proportion', 'dot'),
                                      # ('Male', 'dot'),
                                      ('Utility', 'mean_preference')],

        'desired_other_charts': [
                                # ("Accessions Group", "Race Chart"), ("Accessions Group", "Gender Chart"),
                                #  ("Accessions Group", "Ethnicity Chart"),
                                 ("Accessions Group", "SOC Chart"),
            ]

    }

    # AFSC Measure Chart Versions
    afsc_chart_versions = {"Merit": ["large_only_bar", "bar", "quantity_bar_gradient", "quantity_bar_proportion"],
                           "USAFA Proportion": ["large_only_bar", "bar", "preference_chart", "quantity_bar_proportion"],
                           "Male": ["bar", "preference_chart", "quantity_bar_proportion"],
                           "Combined Quota": ["dot", "quantity_bar"],
                           "Minority": ["bar", "preference_chart", "quantity_bar_proportion"],
                           "Utility": ["bar", "quantity_bar_gradient", "quantity_bar_proportion", "quantity_bar_choice"],
                           "Mandatory": ["dot"], "Desired": ["dot"], "Permitted": ["dot"],
                           "Tier 1": ["dot"], "Tier 2": ["dot"], "Tier 3": ["dot"], "Tier 4": ["dot"],
                           "Norm Score": ["dot", "quantity_bar_proportion", "bar", 'quantity_bar_choice'],
                           'Extra': ['Race Chart', 'Race Chart_proportion', 'Gender Chart', 'SOC Chart',
                                     'Ethnicity Chart', 'Gender Chart_proportion', 'SOC Chart_proportion',
                                     'Ethnicity Chart_proportion']}

    # Colors for the various bar types:
    colors = {

        # Cadet Preference Charts
        "top_choices": "#5490f0", "mid_choices": "#eef09e", "bottom_choices": "#f25d50",
        "Volunteer": "#5490f0", "Non-Volunteer": "#f25d50", "Top 6 Choices": "#5490f0", "7+ Choice": "#f25d50",

        # Quartile Charts
        "quartile_1": "#373aed", "quartile_2": "#0b7532", "quartile_3": "#d1bd4d", "quartile_4": "#cc1414",

        # AFOCD Charts
        "Mandatory": "#311cd4", "Desired": "#085206", "Permitted": "#bda522", "Ineligible": "#f25d50",

        # Cadet Demographics
        "male": "#6531d6", "female": "#73d631", "usafa": "#5ea0bf", "rotc": "#cc9460", "ots": "green",  # TODO: change
        "minority": "#eb8436",
        "non-minority": "#b6eb6c",

        # Misc. AFSC Criteria  #cdddf7
        "large_afscs": "#060d47", "small_afscs": "#3287cd", "merit_above": "#c7b93a", "merit_within": "#3d8ee0",
        "merit_below": "#bf4343", "large_within": "#3d8ee0", "large_else": "#c7b93a",

        # Utility Chart Colors
        "Utility Ascribed": "#4793AF", "Normalized Rank": "#FFC470", "Not Bottom 3": "#DD5746",
        "Not Last Choice": "#8B322C",

        # PGL Charts
        "pgl": "#5490f0", "surplus": "#eef09e", "failed_pgl": "#f25d50",

        # Race Colors
        "American Indian/Alaska Native": "#d46013", "American Indian or Alaska Native": "#d46013",
        "Asian": "#3ad413",
        "Black or African American": "#1100ff", "Native Hawaiian/Pacific Islander": "#d4c013",
        "Native Hawaiian or Other Pacific Islander": "#d4c013",
        "Two or more races": "#ff0026", "Unknown": "#27dbe8", "White": "#a3a3a2",

        # Gender/SOC written differently (need to fix this later)
        "Male": "#6531d6", "Female": "#73d631", "USAFA": "#5ea0bf", "ROTC": "#cc9460", "OTS": "green",  # TODO: Change

        # Accessions group colors
        "All Cadets": "#000000", "Rated": "#ff0011", "USSF": "#0015ff", "NRL": "#000000",

        "Hispanic or Latino": "#66d4ce", "Not Hispanic": "#e09758", "Not Hispanic or Latino": "#e09758",
        "Unknown Ethnicity": "#9e9e9e"

    }

    # Animation Colors
    choice_colors = {1: '#3700ff', 2: '#008dff', 3: '#00e7ff', 4: '#00FF93', 5: '#17FF00',  # #00ff04
                     6: '#BDFF00', 7: '#FFFF00', 8: '#FFCD00', 9: '#FF8700', 10: '#FF5100'}
    mdl_p['all_other_choice_colors'] = '#FF0000'
    mdl_p['choice_colors'] = choice_colors
    mdl_p['interest_colors'] = {'High': '#3700ff', 'Med': '#dad725', 'Low': '#ff9100', 'None': '#ff000a'}
    mdl_p['reserved_slot_color'] = "#ac9853"
    mdl_p['matched_slot_color'] = "#3700ff"
    mdl_p['unfocused_color'] = "#aaaaaa"
    mdl_p['unmatched_color'] = "#aaaaaa"
    mdl_p['exception_edge'] = "#FFD700"
    mdl_p['base_edge'] = 'black'

    # Add these elements to the main dictionary
    mdl_p["afsc_chart_versions"] = afsc_chart_versions
    mdl_p["bar_colors"] = colors

    # Value Function Chart parameters
    mdl_p['ValueFunctionChart'] = {'x_pt': None, 'y_pt': None, 'title': None, 'display_title': True, 'figsize': (10, 10),
                                   'facecolor': 'white', 'save': True, 'breakpoints': None, 'x_ticks': None,
                                   'crit_point': None, 'label_size': 25, 'yaxis_tick_size': 25, 'xaxis_tick_size': 25,
                                   'x_label': None, 'filepath': None, 'graph_color': 'black', 'breakpoint_color': 'black'}

    return mdl_p

determine_afsc_plot_details(instance, results_chart=False)

Configures AFSC chart display parameters based on the instance settings and chart type.

This function adjusts plotting parameters such as which AFSCs to display, label rotation, selected AFSC objective, color schemes, and solution settings. It ensures that the plotting context is consistent with the data variant and chart type requested.

Parameters

instance : object The CadetCareerProblem instance containing parameters, solution data, and metadata used to configure the plot behavior.

results_chart : bool, optional Whether this is a results-oriented plot (e.g., for solution comparison). When True, solution names, objective validation, and plotting colors/markers are configured.

Returns

dict The updated mdl_p dictionary containing plot-specific parameters.

Behavior

  • Automatically determines AFSCs to display (afscs_to_show) based on instance context.
  • Decides whether to skip or rotate AFSC x-axis labels, depending on the number of AFSCs and data source.
  • Ensures a valid AFSC and corresponding objective are selected for chart generation.
  • Validates compatibility of the selected chart version with the chosen objective.
  • Limits solution comparisons to a maximum of 4 solutions unless Multi-Criteria Comparison is specified.
  • Assigns distinct colors, markers, and z-order to each solution when results_chart=True.

Raises

ValueError If an unsupported objective or chart version is selected, or if too many solutions are selected for a results plot.

See Also

Source code in afccp/data/support.py
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
def determine_afsc_plot_details(instance, results_chart=False):
    """
    Configures AFSC chart display parameters based on the instance settings and chart type.

    This function adjusts plotting parameters such as which AFSCs to display, label rotation,
    selected AFSC objective, color schemes, and solution settings. It ensures that the
    plotting context is consistent with the data variant and chart type requested.

    Parameters
    ----------
    instance : object
        The CadetCareerProblem instance containing parameters, solution data, and metadata
        used to configure the plot behavior.

    results_chart : bool, optional
        Whether this is a results-oriented plot (e.g., for solution comparison). When True,
        solution names, objective validation, and plotting colors/markers are configured.

    Returns
    -------
    dict
        The updated `mdl_p` dictionary containing plot-specific parameters.

    Behavior
    --------
    - Automatically determines AFSCs to display (`afscs_to_show`) based on instance context.
    - Decides whether to skip or rotate AFSC x-axis labels, depending on the number of AFSCs and data source.
    - Ensures a valid AFSC and corresponding objective are selected for chart generation.
    - Validates compatibility of the selected chart `version` with the chosen `objective`.
    - Limits solution comparisons to a maximum of 4 solutions unless `Multi-Criteria Comparison` is specified.
    - Assigns distinct colors, markers, and z-order to each solution when `results_chart=True`.

    Raises
    ------
    ValueError
        If an unsupported objective or chart version is selected, or if too many solutions
        are selected for a results plot.

    See Also
    --------
    - [`determine_afscs_in_image`](../../../afccp/reference/data/support/#data.support.determine_afscs_in_image)
    """

    # Shorthand
    p, mdl_p = instance.parameters, instance.mdl_p

    # Get list of AFSCs we're showing
    mdl_p = determine_afscs_in_image(p, mdl_p)

    # Determine if we are "skipping" AFSC labels in the x-axis
    if mdl_p["skip_afscs"] is None:
        if instance.data_variant == "Year" or "CTGAN" in instance.data_name:  # Real AFSCs don't get skipped!
            mdl_p["skip_afscs"] = False
        else:
            if mdl_p["M"] < p['M']:
                mdl_p["skip_afscs"] = False
            else:  # "C2, C4, C6" could be appropriate
                mdl_p["skip_afscs"] = True

    # Determine if we are rotating the AFSC labels in the x-axis
    if mdl_p["afsc_rotation"] is None:
        if mdl_p["skip_afscs"]:  # If we skip the AFSCs, then we don't rotate them
            mdl_p["afsc_rotation"] = 0
        else:

            # We're not skipping the AFSCs, but we could rotate them
            if instance.data_variant == "Year" or "CTGAN" in instance.data_name:
                if mdl_p['M'] > 32:
                    mdl_p["afsc_rotation"] = 80
                elif mdl_p["M"] > 18:
                    mdl_p["afsc_rotation"] = 45
                else:
                    mdl_p["afsc_rotation"] = 0
            else:
                if mdl_p["M"] < 25:
                    mdl_p["afsc_rotation"] = 0
                else:
                    mdl_p["afsc_rotation"] = 45

    # Get AFSC
    if mdl_p["afsc"] is None:
        mdl_p["afsc"] = p['afscs'][0]

    # Get AFSC index
    j = np.where(p["afscs"] == mdl_p["afsc"])[0][0]

    # Get objective
    if mdl_p["objective"] is None:
        k = instance.value_parameters["K^A"][j][0]
        mdl_p["objective"] = instance.value_parameters['objectives'][k]

    # Determine how high above the bars to put the text
    if mdl_p['bar_text_offset'] is None:
        mdl_p['bar_text_offset'] = p['pgl'].max() / 260

    # Figure out which solutions to show, what the colors/markers are going to be, and some error data
    if results_chart:

        # Only applies to AFSC charts
        if mdl_p["results_graph"] in ["Measure", "Value"]:
            if mdl_p["objective"] not in mdl_p["afsc_chart_versions"]:
                raise ValueError("Objective " + mdl_p["objective"] +
                                 " does not have any charts available")

            if mdl_p["version"] not in mdl_p["afsc_chart_versions"][mdl_p["objective"]]:

                if not mdl_p["compare_solutions"]:
                    raise ValueError("Objective '" + mdl_p["objective"] +
                                     "' does not have chart version '" + mdl_p["version"] + "'.")

            if mdl_p["objective"] == "Norm Score" and mdl_p["version"] == "quantity_bar_proportion":
                if "afsc_utility" not in p:
                    raise ValueError("The AFSC Utility matrix is needed for the Norm Score "
                                     "'quantity_bar_proportion' chart. ")

        if mdl_p["solution_names"] is None:

            # Default to the current active solutions
            mdl_p["solution_names"] = list(instance.solutions.keys())
            num_solutions = len(mdl_p["solution_names"])  # Number of solutions in dictionary

            # Get number of solutions
            if mdl_p["num_solutions"] is None:
                mdl_p["num_solutions"] = num_solutions

            # Can't have more than 4 solutions
            if num_solutions > 4:
                mdl_p["num_solutions"] = 4
                mdl_p["solution_names"] = list(instance.solutions.keys())[:4]

        else:
            mdl_p["num_solutions"] = len(mdl_p["solution_names"])
            if mdl_p["num_solutions"] > 4 and mdl_p["results_graph"] != "Multi-Criteria Comparison":
                raise ValueError("Error. Can't have more than 4 solutions shown in the results plot.")

        # Don't need to do this for the Multi-Criteria Comparison chart
        if mdl_p["results_graph"] != "Multi-Criteria Comparison":

            # Load in the colors and markers for each of the solutions
            mdl_p["colors"], mdl_p["markers"], mdl_p["zorder"] = {}, {}, {}
            for s, solution in enumerate(mdl_p["solution_names"]):
                mdl_p["colors"][solution] = mdl_p["color_choices"][s]
                mdl_p["markers"][solution] = mdl_p["marker_choices"][s]
                mdl_p["zorder"][solution] = mdl_p["zorder_choices"][s]

            # This only applies to the Merit and USAFA proportion objectives
            if mdl_p["version"] == "large_only_bar":
                mdl_p["all_afscs"] = False
            else:
                mdl_p["all_afscs"] = True

        # Value Parameters
        if mdl_p["vp_name"] is None:
            mdl_p["vp_name"] = instance.vp_name

    return mdl_p

determine_afscs_in_image(p, mdl_p)

Determines which AFSCs were included in the optimization and which should be displayed in the visualization.

This function updates the mdl_p dictionary with: - AFSCs that were solved for (afscs_in_solution) - AFSCs that should be shown in the plots (afscs) - The corresponding AFSC indices (J) and total count (M)

It handles various ways the user may specify AFSC subsets to visualize, including: - 'All' to show all solved AFSCs - A specific accessions group like 'Rated', 'NRL', or 'USSF' - A commissioning source like 'USAFA', 'ROTC', or 'OTS' - A user-supplied list of AFSC names

If eligibility_limit is set, only AFSCs with a number of eligible cadets below that threshold are included.

Parameters

p : dict The problem parameters, including AFSCs, eligibility mappings, and accessions group information.

mdl_p : dict The model parameters used for chart configuration and visualization. This dictionary is updated in place with the resolved AFSCs to include in plots.

Returns

dict The updated mdl_p dictionary with the following keys updated or added:

  • 'afscs_in_solution': list of AFSCs optimized in the current instance
  • 'afscs': list of AFSCs to display in the current visualization
  • 'J': numpy array of AFSC indices corresponding to afscs
  • 'M': integer count of AFSCs in the chart
Source code in afccp/data/support.py
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
def determine_afscs_in_image(p, mdl_p):
    """
    Determines which AFSCs were included in the optimization and which should be displayed in the visualization.

    This function updates the `mdl_p` dictionary with:
    - AFSCs that were solved for (`afscs_in_solution`)
    - AFSCs that should be shown in the plots (`afscs`)
    - The corresponding AFSC indices (`J`) and total count (`M`)

    It handles various ways the user may specify AFSC subsets to visualize, including:
    - 'All' to show all solved AFSCs
    - A specific accessions group like 'Rated', 'NRL', or 'USSF'
    - A commissioning source like 'USAFA', 'ROTC', or 'OTS'
    - A user-supplied list of AFSC names

    If `eligibility_limit` is set, only AFSCs with a number of eligible cadets below that threshold are included.

    Parameters
    ----------
    p : dict
        The problem parameters, including AFSCs, eligibility mappings, and accessions group information.

    mdl_p : dict
        The model parameters used for chart configuration and visualization. This dictionary is updated in place
        with the resolved AFSCs to include in plots.

    Returns
    -------
    dict
    The updated `mdl_p` dictionary with the following keys updated or added:

    - 'afscs_in_solution': list of AFSCs optimized in the current instance
    - 'afscs': list of AFSCs to display in the current visualization
    - 'J': numpy array of AFSC indices corresponding to `afscs`
    - 'M': integer count of AFSCs in the chart
    """

    # Determine what AFSCs we solved for
    if mdl_p['afscs_solved_for'] == 'All':
        mdl_p['afscs_in_solution'] = p['afscs'][:p['M']]  # All AFSCs (without unmatched)
    else:

        # We solved for some subset of Rated, NRL, or USSF AFSCs
        mdl_p['afscs_in_solution'] = []
        for acc_grp in ['Rated', 'NRL', 'USSF']:

            # If this accessions group was specified by the user
            if acc_grp in mdl_p['afscs_solved_for']:

                # Make sure this is an accessions group for which we have data
                if acc_grp in p['afscs_acc_grp']:

                    # Add each of these AFSCs to the list
                    for afsc in p['afscs_acc_grp'][acc_grp]:
                        mdl_p['afscs_in_solution'].append(afsc)

                # We don't have data on this group
                else:
                    raise ValueError(
                        "Error. Accessions group '" + str(acc_grp) + "' not found in this problem instance.")
        mdl_p['afscs_in_solution'] = np.array(mdl_p['afscs_in_solution'])  # Convert to numpy array

    # Now Determine what AFSCs we want to show in this visualization (must be a subset of AFSCs in the solution)
    if mdl_p['afscs_to_show'] == 'All':
        mdl_p['afscs'] = mdl_p['afscs_in_solution']  # All AFSCs that we solved for

    elif 'USAFA' in mdl_p['afscs_to_show'] or 'ROTC' in mdl_p['afscs_to_show'] or 'OTS' in mdl_p['afscs_to_show']:

        # Determine which SOC we're trying to show
        socs = []
        for soc in p['SOCs']:
            if soc.upper() in mdl_p['afscs_to_show']:
                socs.append(soc)

        # Get list of AFSCs that these SOC(s) can be assigned to
        mdl_p['afscs'] = []
        for j in p['J']:
            if p['acc_grp'][j] == 'NRL':
                mdl_p['afscs'].append(p['afscs'][j])
            else:

                # Make sure we have cadets from this SOC represented by this AFSC
                include = False
                for soc in socs:
                    if len(np.intersect1d(p['I^E'][j], p[f'I^{soc.upper()}'])) > 0:
                        include = True
                        break
                if include:
                    mdl_p['afscs'].append(p['afscs'][j])
        mdl_p['afscs'] = np.array(mdl_p['afscs'])  # Convert to numpy array

    # If the user still supplied a string, we know we're looking for the three accessions groups
    elif type(mdl_p['afscs_to_show']) == str:

        # Loop through each of the three groups
        mdl_p['afscs'] = []
        for acc_grp in ['Rated', 'NRL', 'USSF']:

            # If this accessions group was specified by the user
            if acc_grp in mdl_p['afscs_to_show']:

                # Make sure this is an accessions group for which we have data
                if acc_grp in p['afscs_acc_grp']:

                    # Add each of these AFSCs to the list if they were also in the list of AFSCs we solved for
                    for afsc in p['afscs_acc_grp'][acc_grp]:
                        if afsc in mdl_p['afscs_in_solution']:
                            mdl_p['afscs'].append(afsc)

                # We don't have data on this group
                else:
                    raise ValueError(
                        "Error. Accessions group '" + str(acc_grp) + "' not found in this problem instance.")
        mdl_p['afscs'] = np.array(mdl_p['afscs'])  # Convert to numpy array

    # The user supplied a list of AFSCs
    else:

        # Loop through each AFSC in the supplied list and add it if it is also in the list of AFSCs we solved for
        mdl_p['afscs'] = []
        for afsc in mdl_p['afscs_to_show']:
            if afsc in mdl_p['afscs_in_solution']:
                mdl_p['afscs'].append(afsc)
        mdl_p['afscs'] = np.array(mdl_p['afscs'])  # Convert to numpy array

    # New set of AFSC indices
    mdl_p['J'] = np.array([np.where(p['afscs'] == afsc)[0][0] for afsc in mdl_p['afscs']])
    mdl_p['M'] = len(mdl_p['J'])

    # Determine if we only want to view smaller AFSCs (those with fewer eligible cadets than the specified limit)
    if mdl_p["eligibility_limit"] is not None:

        # Update set of AFSCs
        mdl_p['J'] = np.array([j for j in mdl_p['J'] if len(p['I^E'][j]) <= mdl_p["eligibility_limit"]])
        mdl_p['afscs'] = np.array([p['afscs'][j] for j in mdl_p['J']])
        mdl_p['M'] = len(mdl_p['J'])
    else:
        mdl_p["eligibility_limit"] = p['N']

    return mdl_p

pick_most_changed_afscs(instance)

Identifies the AFSCs with the most variation in cadet assignments across solutions.

This function analyzes multiple solutions in a "Multi-Criteria Comparison" context and selects the AFSCs whose cadet assignments vary the most. For each AFSC, it computes how many cadets are consistently assigned to it across all solutions and compares that to the maximum number of cadets ever assigned to it in any single solution.

Parameters

instance : object An instance of the problem containing:

  • parameters: a dictionary of static problem data
  • solutions: a dictionary of named solution outputs
  • mdl_p: model parameters including 'solution_names' and 'num_afscs_to_compare'

Returns

np.ndarray An array of AFSC names (strings) corresponding to the most changed AFSCs, ranked by variability in cadet assignments.

Examples

afscs = pick_most_changed_afscs(instance)
print("Top variable AFSCs across solutions:", afscs)
Source code in afccp/data/support.py
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
def pick_most_changed_afscs(instance):
    """
    Identifies the AFSCs with the most variation in cadet assignments across solutions.

    This function analyzes multiple solutions in a "Multi-Criteria Comparison" context and selects
    the AFSCs whose cadet assignments vary the most. For each AFSC, it computes how many cadets
    are consistently assigned to it across all solutions and compares that to the maximum number of
    cadets ever assigned to it in any single solution.

    Parameters
    ----------
    instance : object
    An instance of the problem containing:

    - `parameters`: a dictionary of static problem data
    - `solutions`: a dictionary of named solution outputs
    - `mdl_p`: model parameters including 'solution_names' and 'num_afscs_to_compare'

    Returns
    -------
    np.ndarray
        An array of AFSC names (strings) corresponding to the most changed AFSCs,
        ranked by variability in cadet assignments.

    Examples
    --------
    ```python
    afscs = pick_most_changed_afscs(instance)
    print("Top variable AFSCs across solutions:", afscs)
    ```
    """

    # Get necessary info
    p = instance.parameters
    assigned_cadets = {}
    max_assigned = np.zeros(p["M"])
    solution_names = instance.mdl_p["solution_names"]

    # Loop through each solution to get the max number of cadets assigned to each AFSC across each solution
    for solution_name in solution_names:
        solution = instance.solutions[solution_name]
        assigned_cadets[solution_name] = {j: np.where(solution['j_array'] == j)[0] for j in p["J"]}
        num_assigned = np.array([len(assigned_cadets[solution_name][j]) for j in p["J"]])
        max_assigned = np.array([max(max_assigned[j], num_assigned[j]) for j in p["J"]])

    # Loop through each AFSC to get the number of cadets shared across each solution for each AFSC
    shared_cadet_count = np.zeros(p["M"])
    for j in p["J"]:

        # Pick the first solution as a baseline
        baseline_cadets = assigned_cadets[solution_names[0]][j]

        # Loop through each cadet assigned to this AFSC in the "baseline" solution
        for i in baseline_cadets:

            # Check if the cadet is assigned to this AFSC in all solutions
            cadet_in_each_solution = True
            for solution_name in solution_names:
                if i not in assigned_cadets[solution_name][j]:
                    cadet_in_each_solution = False
                    break

            # If the cadet is assigned in all solutions, we add one to the shared count
            if cadet_in_each_solution:
                shared_cadet_count[j] += 1

    # The difference between the maximum number of cadets assigned to a given AFSC across all solutions and the number
    # of cadets that are common to said AFSC across all solutions is our "Delta"
    delta_afsc = max_assigned - shared_cadet_count

    # Return the AFSCs that change the most
    indices = np.argsort(delta_afsc)[::-1]
    afscs = p["afsc_vector"][indices][:instance.mdl_p["num_afscs_to_compare"]]
    return afscs

cip_to_qual_tiers(afscs, cip1, cip2=None, cip3=None, business_hours=None, true_tiers=True)

Generate qualification tiers for cadets based on CIP codes and AFSCs. Current as of Oct '2024

This function determines the qualification tiers (e.g., M1, D2, P3, I4) for a set of cadets based on the Classification of Instructional Programs (CIP) codes associated with their degrees. It evaluates the suitability of each cadet for each Air Force Specialty Code (AFSC) using official AFOCD guidance and updates from career field managers.

If multiple CIP sources are available (e.g., primary, secondary, tertiary degrees), the function returns the best qualifying tier across all provided sources.

Parameters

  • afscs : list of str List of Air Force Specialty Codes (AFSCs) to evaluate against cadet degree qualifications.
  • cip1 : numpy.ndarray Primary degree CIP codes for each cadet. Expected to be a string or numeric array of length N.
  • cip2 : numpy.ndarray, optional Secondary degree CIP codes for each cadet (if available).
  • cip3 : numpy.ndarray, optional Tertiary or external CIP codes (e.g., scraped from catalog websites or manually added).
  • business_hours : numpy.ndarray, optional Number of business-related credit hours per cadet. Used for disambiguation in certain AFSCs (e.g., 63A).
  • true_tiers : bool, default=True If True, applies refined qualification tiers based on updates from CFMs and AFOCD (as of June 2023 and later).

Returns

  • numpy.ndarray A matrix of shape (N, M), where each entry is a qualification tier string (e.g., 'M1', 'D2', 'P3', 'I4') for cadet i and AFSC j.

Examples

afscs = ['17X', '62EXE', '21R']
cip1 = np.array(['110102', '141001', '520409'])
qual_matrix = cip_to_qual_tiers(afscs, cip1)
print(qual_matrix)

Details

  • Qualification tiers follow AFOCD conventions:

    • M = Mandatory
    • D = Desired
    • P = Permitted
    • I = Ineligible
    • The second number (e.g., 1 in 'M1') indicates how strong the tier is within that category (1 = best).
    • If multiple CIP sources are provided, the best (lowest) tier number is selected for each cadet–AFSC pair.
    • Covers dozens of AFSCs and handles specific CIP-to-AFSC mappings with multiple tiers per AFSC.

Notes

  • Includes recent updates from AFOCD Oct '24 and refinements from Air Force CFMs.
  • Used in the cadet-career field qualification model to help filter and prioritize match options.
Source code in afccp/data/support.py
 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
 730
 731
 732
 733
 734
 735
 736
 737
 738
 739
 740
 741
 742
 743
 744
 745
 746
 747
 748
 749
 750
 751
 752
 753
 754
 755
 756
 757
 758
 759
 760
 761
 762
 763
 764
 765
 766
 767
 768
 769
 770
 771
 772
 773
 774
 775
 776
 777
 778
 779
 780
 781
 782
 783
 784
 785
 786
 787
 788
 789
 790
 791
 792
 793
 794
 795
 796
 797
 798
 799
 800
 801
 802
 803
 804
 805
 806
 807
 808
 809
 810
 811
 812
 813
 814
 815
 816
 817
 818
 819
 820
 821
 822
 823
 824
 825
 826
 827
 828
 829
 830
 831
 832
 833
 834
 835
 836
 837
 838
 839
 840
 841
 842
 843
 844
 845
 846
 847
 848
 849
 850
 851
 852
 853
 854
 855
 856
 857
 858
 859
 860
 861
 862
 863
 864
 865
 866
 867
 868
 869
 870
 871
 872
 873
 874
 875
 876
 877
 878
 879
 880
 881
 882
 883
 884
 885
 886
 887
 888
 889
 890
 891
 892
 893
 894
 895
 896
 897
 898
 899
 900
 901
 902
 903
 904
 905
 906
 907
 908
 909
 910
 911
 912
 913
 914
 915
 916
 917
 918
 919
 920
 921
 922
 923
 924
 925
 926
 927
 928
 929
 930
 931
 932
 933
 934
 935
 936
 937
 938
 939
 940
 941
 942
 943
 944
 945
 946
 947
 948
 949
 950
 951
 952
 953
 954
 955
 956
 957
 958
 959
 960
 961
 962
 963
 964
 965
 966
 967
 968
 969
 970
 971
 972
 973
 974
 975
 976
 977
 978
 979
 980
 981
 982
 983
 984
 985
 986
 987
 988
 989
 990
 991
 992
 993
 994
 995
 996
 997
 998
 999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
def cip_to_qual_tiers(afscs, cip1, cip2=None, cip3=None, business_hours=None, true_tiers=True):
    """
    Generate qualification tiers for cadets based on CIP codes and AFSCs. Current as of Oct '2024

    This function determines the qualification tiers (e.g., M1, D2, P3, I4) for a set of cadets based on the
    Classification of Instructional Programs (CIP) codes associated with their degrees. It evaluates the suitability
    of each cadet for each Air Force Specialty Code (AFSC) using official AFOCD guidance and updates from
    career field managers.

    If multiple CIP sources are available (e.g., primary, secondary, tertiary degrees), the function returns the
    best qualifying tier across all provided sources.

    Parameters
    ----------
    - afscs : list of str
        List of Air Force Specialty Codes (AFSCs) to evaluate against cadet degree qualifications.
    - cip1 : numpy.ndarray
        Primary degree CIP codes for each cadet. Expected to be a string or numeric array of length N.
    - cip2 : numpy.ndarray, optional
        Secondary degree CIP codes for each cadet (if available).
    - cip3 : numpy.ndarray, optional
        Tertiary or external CIP codes (e.g., scraped from catalog websites or manually added).
    - business_hours : numpy.ndarray, optional
        Number of business-related credit hours per cadet. Used for disambiguation in certain AFSCs (e.g., 63A).
    - true_tiers : bool, default=True
        If True, applies refined qualification tiers based on updates from CFMs and AFOCD (as of June 2023 and later).

    Returns
    -------
    - numpy.ndarray
        A matrix of shape (N, M), where each entry is a qualification tier string (e.g., 'M1', 'D2', 'P3', 'I4') for
        cadet i and AFSC j.

    Examples
    --------
    ```python
    afscs = ['17X', '62EXE', '21R']
    cip1 = np.array(['110102', '141001', '520409'])
    qual_matrix = cip_to_qual_tiers(afscs, cip1)
    print(qual_matrix)
    ```

    Details
    -------
    - Qualification tiers follow AFOCD conventions:

        - M = Mandatory
        - D = Desired
        - P = Permitted
        - I = Ineligible
    - The second number (e.g., 1 in 'M1') indicates how strong the tier is within that category (1 = best).
    - If multiple CIP sources are provided, the best (lowest) tier number is selected for each cadet–AFSC pair.
    - Covers dozens of AFSCs and handles specific CIP-to-AFSC mappings with multiple tiers per AFSC.

    Notes
    -----
    - Includes recent updates from AFOCD Oct '24 and refinements from Air Force CFMs.
    - Used in the cadet-career field qualification model to help filter and prioritize match options.
    """

    # AFOCD CLASSIFICATION
    N = len(cip1)  # Number of Cadets
    M = len(afscs)  # Number of AFSCs we're building the qual matrix for

    # Dictionary of CIP codes
    cips = {1: cip1, 2: cip2, 3: cip3}

    # List of CIP degrees that are not empty
    degrees = []
    for d in cips:
        if cips[d] is not None:
            degrees.append(d)

    # Initialize qual dictionary
    qual = {}

    # Loop through both sets of degrees (if applicable)
    for d in degrees:

        # Initialize qual matrix (for this set of degrees)
        qual[d] = np.array([["I5" for _ in range(M)] for _ in range(N)])

        # Loop through each cadet and AFSC pair
        for i in range(N):
            cip = str(cips[d][i])
            cip = "0" * (6 - len(cip)) + cip
            for j, afsc in enumerate(afscs):

                # Rated Career Fields
                if afsc in ["11U", "11XX", "12XX", "13B", "18X", "92T0", "92T1", "92T2", "92T3",
                            "11XX_R", "11XX_U", "11XX_O", "USSF", "USSF_U", "USSF_R"]:
                    qual[d][i, j] = "P1"

                # Aerospace Physiologist
                elif afsc == '13H':  # Proportions/Degrees Updated Oct '23
                    if cip in ['302701', '260912', '310505', '260908', '260707', '260403']:
                        qual[d][i, j] = 'M1'
                    elif cip in ['290402', '261501', '422813'] or cip[:4] in ['2609']:
                        qual[d][i, j] = 'P2'
                    else:
                        qual[d][i, j] = 'I3'

                # Airfield Ops
                elif afsc == '13M':  # Proportions Updated Oct '23
                    if cip == '290402' or cip[:4] == '4901':
                        qual[d][i, j] = 'D1'
                    elif cip[:4] in ['5201', '5202', '5206', '5207', '5211', '5212', '5213',
                                     '5214', '5218', '5219', '5220']:
                        qual[d][i, j] = 'D2'
                    else:
                        qual[d][i, j] = 'P3'

                # Nuclear and Missile Operations
                elif afsc == '13N':  # Updated Oct '23
                    qual[d][i, j] = 'P1'

                # Space Operations
                elif afsc in ['13S', '13S1S']:
                    m_list4 = ['1402', '1410', '1419', '1427', '4002']
                    d_list4 = ['1101', '1102', '1104', '1105', '1107', '1108', '1109', '1110', '1404', '1406', '1407',
                               '1408', '1409', '1411', '1412', '1413', '1414', '1418', '1420', '1423', '1432', '1435',
                               '1436', '1437', '1438', '1439', '1441', '1442', '1444', '3006', '3008', '3030', '4008']
                    d_list6 = ['140101', '290203', '290204', '290205', '290207', '290301', '290302', '290304']
                    if cip[:4] in m_list4 or cip[:2] == '27' or cip == '290305':
                        qual[d][i, j] = 'M1'
                    elif cip[:4] in d_list4 or cip in d_list6:
                        qual[d][i, j] = 'D2'
                    else:
                        qual[d][i, j] = 'P3'

                # Information Operations
                elif afsc == '14F':
                    m_list4 = ['3017', '4201', '4227', '4511']
                    d_list4 = ['5214', '3023', '3026']
                    p_list4 = ['4509', '4502', '3025', '0901']
                    if cip[:4] in m_list4:
                        qual[d][i, j] = 'M1'
                    elif cip[:4] in d_list4 or cip in ["090902", "090903", "090907"]:
                        qual[d][i, j] = 'D2'
                    elif cip[:4] in p_list4:
                        qual[d][i, j] = 'P3'
                    else:
                        qual[d][i, j] = 'I4'

                # Intelligence
                elif afsc in ['14N', '14N1S']:
                    m_list2 = ['05', '16', '22', '24', '28', '29', '45', '54']
                    d_list2 = ['13', '09', '23', '28', '29', '30', '35', '38', '42', '43', '52']
                    if cip[:2] in ['11', '14', '27', '40'] or cip == '307001':
                        qual[d][i, j] = 'M1'
                    elif cip[:2] in m_list2 or cip == '301701':
                        qual[d][i, j] = 'M2'
                    elif cip[:2] in d_list2 or cip == '490101':
                        qual[d][i, j] = 'D3'
                    else:
                        qual[d][i, j] = 'P4'

                # Operations Research Analyst
                elif afsc in ['15A', '61A']:
                    m_list4 = ['1437', '1435', '3070', '3030', '3008']
                    if cip[:4] in m_list4 or cip[:2] == '27' or cip == '110102':
                        qual[d][i, j] = 'M1'
                    elif cip in ['110804', '450603'] or cip[:4] in ['1427', '1107', '3039', '3049']:
                        qual[d][i, j] = 'D2'
                    elif (cip[:2] == '14' and cip != '140102') or cip[:4] in ['4008', '4506', '2611', '3071', '5213']:
                        qual[d][i, j] = 'P3'
                    else:
                        qual[d][i, j] = 'I4'

                # Weather and Environmental Sciences (Current a/o Apr '24 AFOCD)
                elif afsc == '15W':
                    if cip[:4] == '4004':
                        qual[d][i, j] = 'M1'
                    elif cip in ['270301', '270303', '270304', '303501', '303001', '140802',
                                 '303801', '141201', '141301', '400601', '400605', '400607', '400801', '400805',
                                 '400806', '400807', '400809']:
                        qual[d][i, j] = 'P2'
                    elif cip[:2] in ['40'] or cip in ['040999', '030104', '110102', '110101', '110803', '110201',
                                                      '110701', '110802', '110104', '110804']:
                        qual[d][i, j] = 'P3'

                    else:
                        qual[d][i, j] = 'I4'

                # Cyberspace Operations
                elif afsc in ['17D', '17S', '17X', '17S1S']:
                    m_list6 = ['150303', '151202', '290207', '303001', '307001', '270103', '270303', '270304']
                    d_list4 = ['1503', '1504', '1508', '1512', '1514', '4008', '4005']
                    if cip[:4] in ['3008', '3016', '5212'] or cip in m_list6 or \
                            (cip[:2] == '11' and cip[:4] not in ['1103', '1106']) or (
                            cip[:2] == '14' and cip != '140102'):
                        qual[d][i, j] = 'M1'
                    elif cip[:4] in d_list4 or cip[:2] == '27':
                        qual[d][i, j] = 'D2'
                    else:
                        qual[d][i, j] = 'P3'

                # Aircraft Maintenance
                elif afsc == '21A':  # Proportions Updated Oct '23
                    d_list4 = ['5202', '5206', '1101', '1102', '1103', '1104', '1107', '1110', '5212']
                    if cip[:2] == '14':
                        qual[d][i, j] = 'D1'
                    elif cip[:4] in d_list4 or cip[:2] == '40' or cip in ['151501', '520409', '490104', '490101']:
                        qual[d][i, j] = 'D2'
                    else:
                        qual[d][i, j] = 'P3'

                # Munitions and Missile Maintenance
                elif afsc == '21M':  # Proportions Updated Oct '23
                    d_list4 = ['1107', '1101', '1110', '5202', '5206', '5213']
                    d_list2 = ['27', '40']

                    # Added "Data Processing" (no CIPs in AFOCD, and others are already captured in other tiers)
                    d_list6 = ['290407', '290408', '151501', '520409', "110301"]
                    if cip[:2] == "14":
                        qual[d][i, j] = 'D1'
                    elif cip[:2] in d_list2 or cip[:4] in d_list4 or cip in d_list6:
                        qual[d][i, j] = 'D2'
                    else:
                        qual[d][i, j] = 'P3'

                # Logistics Readiness: Conversations w/CFMs changed this!
                elif afsc == '21R':
                    if true_tiers:  # More accurate than current AFOCD a/o Oct '23
                        cip_list = ['520203', '520409', '142501', '490199', '520209', '499999', '521001', '520201',
                                    '140101', '143501', '280799', '450601', '520601', '520304', '520899', '520213',
                                    '520211', '143701', '110802']
                        if cip in cip_list:
                            qual[d][i, j] = 'D1'
                        else:
                            qual[d][i, j] = 'P2'
                    else:
                        d_list4 = ['1101', '1102', '1103', '1104', '1107', '1110', '4506', '5202', '5203', '5206',
                                   '5208']
                        d_list6 = ['151501', '490101', '520409']

                        # Added Ops Research and Data Processing (no CIPs in AFOCD)
                        d_list6_add = ['143701', '110301']
                        if cip[:4] in ['1425', '1407']:
                            qual[d][i, j] = 'D1'
                        elif cip[:4] in d_list4 or cip in d_list6 or cip in d_list6_add or cip[:3] == "521":
                            qual[d][i, j] = 'D2'
                        else:
                            qual[d][i, j] = 'P3'

                # Security Forces
                elif afsc == '31P':  # Updated Oct '23
                    qual[d][i, j] = 'P1'

                # Civil Engineering: Architect/Architectural Engineer
                elif afsc == '32EXA':
                    if cip[:4] == '0402' or cip in ['140401']:  # Sometimes 402010 is included
                        qual[d][i, j] = 'M1'
                    else:
                        qual[d][i, j] = 'I2'

                # Civil Engineering: Civil Engineer
                elif afsc == '32EXC':
                    if cip[:4] == '1408':
                        qual[d][i, j] = 'M1'
                    else:
                        qual[d][i, j] = 'I2'

                # Civil Engineering: Electrical Engineer  *added 1447 per CFM conversation 2 Jun '23
                elif afsc == '32EXE':
                    if cip[:4] in ['1410', '1447']:
                        qual[d][i, j] = 'M1'
                    else:
                        qual[d][i, j] = 'I2'

                # Civil Engineering: Mechanical Engineer
                elif afsc == '32EXF':
                    if cip == '141901':
                        qual[d][i, j] = 'M1'
                    else:
                        qual[d][i, j] = 'I2'

                # Civil Engineering: General Engineer  *Updated AFOCD a/o 30 Apr '23 w/further adjustments a/o 2 Jun '23
                elif afsc == '32EXG':
                    if cip[:4] in ['1408', '1410'] or cip in ['140401', '141401', '141901', '143301', '143501',
                                                              '144701']:
                        qual[d][i, j] = 'M1'
                    elif cip in ["140701"] or cip[:4] in ["1405", "1425", "1402", "5220", '1510']:  # added 1510
                        qual[d][i, j] = 'D2'  # FY23 added a desired tier to 32EXG!
                    else:
                        qual[d][i, j] = 'I3'

                # Civil Engineering: Environmental Engineer
                elif afsc == '32EXJ':
                    if cip == '141401':
                        qual[d][i, j] = 'M1'
                    else:
                        qual[d][i, j] = 'I2'

                # Public Affairs
                elif afsc == '35P':
                    if cip[:2] == '09':
                        qual[d][i, j] = 'M1'
                    elif cip[:4] in ['2313', '4509', '4510', '5214'] or cip[:2] == '42':
                        qual[d][i, j] = 'D2'
                    else:
                        qual[d][i, j] = 'P3'

                # Force Support
                elif afsc in ['38F', '38P']:  # Updated Oct '24
                    d_list4 = ['4404', '5202', '5210', '5214']
                    if cip[:4] == ['4506', '3017'] or cip in ['143701', '520213']:
                        qual[d][i, j] = 'M1'
                    elif cip[:2] == ['13', '27', '42'] or cip[:4] in d_list4 or cip in ['301701', '450901',
                                                                                        '520304', '520901']:
                        qual[d][i, j] = 'D2'
                    else:
                        qual[d][i, j] = 'P3'

                # Old 14F (Information Operations)
                elif afsc == '61B':
                    m_list4 = ['3017', '4502', '4511', '4513', '4514', '4501']
                    if cip[:2] == '42' or cip[:4] in m_list4 or cip in ['450501', '451201']:
                        qual[d][i, j] = 'M1'
                    else:
                        qual[d][i, j] = 'I2'

                # Chemist/Nuclear Chemist
                elif afsc == '61C':
                    d_list6 = ['140601', '141801', '143201', '144301', '144401', '260299']
                    if cip[:4] in ['1407', '4005'] or cip in ['260202', '260205']:
                        qual[d][i, j] = 'M1'
                    elif cip in d_list6 or cip[:5] == '26021' or cip[:4] == '4010':
                        qual[d][i, j] = 'D2'
                    elif cip in ['140501', '142001', '142501', '144501']:
                        qual[d][i, j] = 'P3'
                    else:
                        qual[d][i, j] = 'I4'

                # Physicist/Nuclear Engineer
                elif afsc == '61D':
                    if cip[:4] in ['1412', '1423', '4002', '4008']:
                        qual[d][i, j] = 'M1'
                    else:
                        qual[d][i, j] = 'I2'

                # Developmental Engineering: Aeronautical Engineer
                elif afsc in ['62EXA', '62E1A1S']:
                    if cip[:4] == '1402':
                        qual[d][i, j] = 'M1'
                    else:
                        qual[d][i, j] = 'I2'

                # Developmental Engineering: Astronautical Engineer
                elif afsc in ['62EXB', '62E1B1S']:
                    if cip[:4] == '1402':
                        qual[d][i, j] = 'M1'
                    else:
                        qual[d][i, j] = 'I2'

                # Developmental Engineering: Computer Systems Engineer
                elif afsc in ['62EXC', '62E1C1S']:  # Updated Oct '24
                    if cip[:4] == '1409':
                        qual[d][i, j] = 'M1'
                    elif cip[:4] == '1101' or cip == '110701':
                        qual[d][i, j] = 'D2'
                    else:
                        qual[d][i, j] = 'I3'

                # Developmental Engineering: Electrical/Electronic Engineer
                elif afsc in ['62EXE', '62E1E1S']:
                    if cip[:4] in ['1410', '1447']:
                        qual[d][i, j] = 'M1'
                    else:
                        qual[d][i, j] = 'I2'

                # Developmental Engineering: Flight Test Engineer
                elif afsc == '62EXF':
                    if cip[:2] in ['27', '40'] or (cip[:2] == '14' and cip != '140102'):
                        qual[d][i, j] = 'M1'
                    else:
                        qual[d][i, j] = 'I2'

                # Developmental Engineering: Project/General Engineer
                elif afsc in ['62EXG', '62E1G1S']:  # Updated Oct '24
                    if cip[:2] == '14' and cip not in ['140102', '141001', '144701'] and cip[:4] not in ["1437",
                                                                                                         "1408"]:
                        qual[d][i, j] = 'M1'
                    else:
                        qual[d][i, j] = 'I2'

                # Developmental Engineering: Mechanical Engineer
                elif afsc in ['62EXH', '62E1H1S']:  # Updated Oct '24
                    if cip[:4] == '1419':  # and cip != '141901': (Is this a mistake? 141901 seems to be popular among
                        qual[d][i, j] = 'M1'  # cadets that want this AFSC)
                    else:
                        qual[d][i, j] = 'I2'

                # Developmental Engineering: Systems/Human Factors Engineer
                elif afsc in ['62EXI', '62EXS', '62E1I1S']:
                    if cip[:4] in ['1427', '1435']:
                        qual[d][i, j] = 'M1'
                    else:
                        qual[d][i, j] = 'I2'

                # Acquisition Manager
                elif afsc in ['63A', '63A1S']:
                    if cip[:2] in ['14', '40']:
                        qual[d][i, j] = 'M1'
                    elif cip[:2] in ['11', '27'] or cip[:4] == '4506' or (cip[:2] == '52' and cip[:4] != '5204'):
                        qual[d][i, j] = 'D2'
                    else:
                        if business_hours is not None:
                            if business_hours[i] >= 24:
                                qual[d][i, j] = 'P3'
                            else:
                                qual[d][i, j] = 'I4'
                        else:
                            qual[d][i, j] = 'P3'

                # Contracting
                elif afsc == '64P':
                    d_list2 = ['28', '44', '54', '16', '23', '05', '42']
                    if cip[:2] == "52":
                        qual[d][i, j] = 'D1'
                    elif cip[:2] in ['14', '15', '26', '27', '29', '40', '41']:
                        qual[d][i, j] = 'D2'
                    elif cip[:2] in d_list2 or (cip[:2] == '45' and cip[:4] != '4506') or \
                            cip[:4] in ['2200', '2202'] or cip == '220101':
                        qual[d][i, j] = 'D3'
                    else:
                        qual[d][i, j] = 'P4'

                # Financial Management
                elif afsc == '65F':
                    if cip[:4] in ['4506', '5203', '5206', '5213', '5208']:
                        qual[d][i, j] = 'D1'
                    elif cip[:2] in ['27', '52', '14']:
                        qual[d][i, j] = 'D2'
                    else:
                        qual[d][i, j] = 'P3'

                # This shouldn't happen... but here's some error checking!
                else:
                    raise ValueError("Error. AFSC '" + str(afsc) + "' not a valid AFSC that this code recognizes.")

    # If no other CIP list is specified, we just take the qual matrix from the first degrees
    if len(degrees) == 1:
        qual_matrix = copy.deepcopy(qual[1])

    # If CIP2 is specified, we take the highest tier that the cadet qualifies for
    else:
        qual_matrix = np.array([["I5" for _ in range(M)] for _ in range(N)])

        # Loop though each cadet and AFSC pair
        for i in range(N):
            for j in range(M):

                # Get degree tier qualifications from both degrees
                qual_1 = qual[1][i, j]
                qual_2 = qual[2][i, j]

                if cip3 is not None:
                    qual_3 = qual[3][i, j]
                else:
                    qual_3 = "I9"  # Dummy value

                # Determine which qualification is best
                if int(qual_1[1]) < int(qual_2[1]):  # D1 beats D2
                    if int(qual_1[1]) < int(qual_3[1]): # D1 beats D3
                        qual_matrix[i, j] = qual_1  # Qual 1 wins!
                    else:  # D3 beats D1
                        qual_matrix[i, j] = qual_3  # Qual 3 wins!
                else:  # D2 beats D1
                    if int(qual_2[1]) < int(qual_3[1]):  # D2 beats D3
                        qual_matrix[i, j] = qual_2  # Qual 2 wins!
                    else:  # D3 beats D2
                        qual_matrix[i, j] = qual_3  # Qual 3 wins!

    return qual_matrix