simulation_analysis.capacity

This module provides functions to analyze the capacity of a queueing system, calculate the ultimate capacity, and solve differential equations related to queue dynamics.

  1"""
  2This module provides functions to analyze the capacity of a queueing system,
  3calculate the ultimate capacity, and solve differential equations related to queue dynamics.
  4"""
  5import numpy as np
  6from scipy.optimize import minimize
  7from scipy.integrate import solve_ivp
  8from scipy.optimize import minimize
  9import numpy as np
 10import matplotlib.pyplot as plt
 11import sys
 12import os
 13import glob
 14
 15
 16# parent_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
 17
 18# # Add parent directory to sys.path
 19# sys.path.append(parent_dir)
 20
 21from constants import SCENARIO_NAME, NUM_MONTHS, ARRIVAL_INCREASE_FACTOR
 22
 23
 24
 25def calculate_mean_wait_time(arrival_rates, queue_lengths, input_mean_wait_time, logfilename):
 26    """
 27    Calculate the mean wait time for a queueing system given arrival rates and queue lengths.
 28
 29    Args:
 30        arrival_rates (list): List of 3 arrival rates for the queues.
 31        queue_lengths (list): List of 3 queue lengths for the queues.
 32        input_mean_wait_time (float): The input mean wait time to compare against.
 33
 34    Returns:
 35        dict: A dictionary with calculated service rates, mean wait time, and relative error.
 36    """
 37    print("\nCapacity Analysis")
 38    print("Arrival Rates: ", arrival_rates)
 39    print("Queue Lengths: ", queue_lengths)
 40    with open(logfilename, 'a') as f:
 41        f.write("\nCapacity Analysis\n")
 42        f.write("Arrival Rates: " + str(arrival_rates) + "\n")
 43        f.write("Queue Lengths: " + str(queue_lengths) + "\n")
 44    arrival_rates = np.array(arrival_rates)
 45    queue_lengths = np.array(queue_lengths)
 46    eps = 1e-6
 47
 48    # Define the fitting function
 49    def fitting_function(mu, arrival_rates, queue_lengths):
 50        numerator = np.sum(arrival_rates / mu**2)
 51        denominator = 1 - (arrival_rates / mu).sum()
 52        return (numerator / denominator) * arrival_rates - queue_lengths
 53
 54    # Objective function for minimization
 55    def objective_function(mu, arrival_rates, queue_lengths):
 56        return np.sum(fitting_function(mu, arrival_rates, queue_lengths)**2)
 57
 58    # Constraint to ensure the stability condition
 59    def stability_constraint(mu, arrival_rates):
 60        return 1 - (arrival_rates / mu).sum()
 61
 62    # Initial guess for service rate
 63    initial_guess = [1.1 * float(arrival_rates.sum())]
 64
 65    # Define constraints
 66    constraints = {
 67        'type': 'ineq',
 68        'fun': stability_constraint,
 69        'args': (arrival_rates,)
 70    }
 71
 72    # Perform optimization
 73    result = minimize(
 74        objective_function,
 75        initial_guess,
 76        method='SLSQP',
 77        args=(arrival_rates, queue_lengths),
 78        constraints=constraints,
 79        options={'ftol': 1e-12, 'maxiter': 1000}
 80    )
 81
 82    if result.success:
 83        mu = result.x[0]
 84        print(f"Calculated service rate (mu): {mu:.2f}")
 85        with open(logfilename, 'a') as f:
 86            f.write(f"Calculated service rate (mu): {mu:.2f}\n")
 87    else:
 88        raise ValueError("Optimization failed: " + result.message)
 89
 90    # Calculate mean wait time
 91    numerator_W_q = np.sum(arrival_rates / mu**2)
 92    denominator_W_q = 1 - np.sum(arrival_rates / mu)
 93    calculated_mean_wait_time = numerator_W_q / denominator_W_q
 94
 95    print(f"Calculated mean wait time: {calculated_mean_wait_time:.2f}")
 96    print(f"Input mean wait time: {input_mean_wait_time:.2f}")
 97    with open(logfilename, 'a') as f:
 98        f.write(
 99            f"Calculated mean wait time: {calculated_mean_wait_time:.2f}\n")
100        f.write(f"Input mean wait time: {input_mean_wait_time:.2f}\n")
101
102    # Calculate relative error
103    relative_error = (calculated_mean_wait_time -
104                      input_mean_wait_time) / input_mean_wait_time * 100
105    print(f"Relative error (%): {relative_error:.2f}")
106
107    # Return results as a dictionary
108    output = {
109        'Service Rate': mu,
110        'Calculated Mean Wait Time': calculated_mean_wait_time,
111        'Input Mean Wait Time': input_mean_wait_time,
112        'Relative Error (%)': relative_error
113    }
114
115    print("Capacity Analysis Complete")
116    print("Relative Error: ", relative_error)
117    with open(logfilename, 'a') as f:
118        f.write("Relative Error: " + str(relative_error) + "\n")
119    return output
120
121
122def dN_dlambda(lambda_, T, N, Cu, theta):
123    """
124    Differential equation for N with respect to lambda.
125    Args:
126        lambda_ (float): The current value of lambda.
127        T (float): Total time in hours.
128        N (float): Current value of N.
129        Cu (float): Ultimate capacity.
130        theta (float): Degree of congestion.
131    Returns:
132        float: The derivative of N with respect to lambda.
133    """
134    return T * (1 - (N / (T * Cu))**theta)
135
136
137def solve_N(lambda_eval, Cu, T, theta):
138    """
139    Solve the ODE for N using the initial condition N(0) = 0.
140    Args:
141        lambda_eval (array): Array of lambda values for evaluation.
142        Cu (float): Ultimate capacity.
143        T (float): Total time in hours.
144        theta (float): Degree of congestion.
145    Returns:
146        array: Solution for N at the given lambda values.
147    """
148    sol = solve_ivp(
149        fun=lambda lam, N: dN_dlambda(lam, T, N, Cu, theta),
150        t_span=(0, lambda_eval[-1]),
151        y0=[0],
152        t_eval=lambda_eval,
153        method='RK45',
154        rtol=1e-8,
155        atol=1e-8
156    )
157    return sol.y[0]
158
159
160def objective(params, constant_args):
161    """
162    Objective function to minimize the difference between predicted and actual N values.
163    Args:
164        params (list): List containing Cu and theta.
165        constant_args (list): List containing a, b, c, d, and T.
166    Returns:
167        float: Sum of squared differences between predicted and actual N values.
168    """
169    Cu, theta = params
170    a, b, c, d, T = constant_args
171    lambda_vals = np.array([a, c])
172    N_vals = np.array([b, d])
173    N_pred = solve_N(lambda_vals, Cu, T, theta)
174    return np.sum((N_pred - N_vals)**2)
175
176def calculate_ultimate_capacity() -> tuple[float, float]:
177    """
178    Auto‐discover all scenario logs (BASE + others), but only read
179    'Operating capacity:' from the BASE file. Fit dN/dλ = T*(1 - (N/(Cu*T))**θ),
180    write results, and plot every sample labeled.
181    """
182    # 1) Setup
183    T = NUM_MONTHS * 30 * 24
184    LOG_DIR = 'bottleneckAnalysis/logs'
185
186    # 2) Identify files
187    base_file = os.path.join(LOG_DIR, f'{SCENARIO_NAME}_BASE.txt')
188    pattern   = os.path.join(LOG_DIR, f'{SCENARIO_NAME}_*.txt')
189    all_files = sorted(glob.glob(pattern))
190    high_files = [f for f in all_files if f != base_file]
191
192    # 3) Read BASE file
193    lambdas = []
194    Ns      = []
195    Ns_actual = []
196    op_capacity = None
197
198
199    with open(base_file) as f:
200        lam0 = N0 = None
201        for line in f:
202            if line.startswith('Operating capacity:'):
203                op_capacity = float(line.split()[2])
204            elif line.startswith('Arrival rate:'):
205                lam0 = float(line.split()[2])
206            elif line.startswith('Exited ships:'):
207                N0 = float(line.split()[2])
208        if lam0 is None or N0 is None:
209            raise RuntimeError(f"Could not parse λ or N from BASE file {base_file}")
210        lambdas.append(lam0)
211        Ns.append(N0)
212        Ns_actual.append(N0)
213
214    # 4) Read every non-BASE file (only λ and N)
215    for fn in high_files:
216        lam = Ni = None
217        with open(fn) as f:
218            for line in f:
219                if line.startswith('Arrival rate:'):
220                    lam = float(line.split()[2])
221                elif line.startswith('Exited ships:'):
222                    Ni = float(line.split()[2])
223        if lam is not None and Ni is not None:
224            lambdas.append(lam)
225            Ns.append(Ni)
226            Ns_actual.append(Ni)
227        else:
228            print(f"Skipping {fn}: missing Arrival rate or Exited ships")
229
230        
231    lambdas = np.array(lambdas)
232    Ns      = np.array(Ns)
233
234    sort_idx = np.argsort(lambdas)
235    lambdas = lambdas[sort_idx]
236    Ns = Ns[sort_idx]
237
238    print("Discovered sample points:")
239    for i, (λ, N) in enumerate(zip(lambdas, Ns), start=1):
240        print(f"  sample point {i}: λ = {λ:.2f},  N = {N:.2f}")
241
242    # 5) Define multi‐point objective
243    def multi_obj(x, lam_arr, N_arr, T):
244        Cu, θ = x
245        N_pred = solve_N(lam_arr, Cu, T, θ)
246        return np.sum((N_pred - N_arr) ** 2)
247
248    # 6) Optimize
249    initial = [max(Ns)/T, 5.0]
250    bounds  = [(0.1, max(lambdas)), (0.1, 20.0)] #TODO: adjust bounds if needed
251    res = minimize(multi_obj, x0=initial, args=(lambdas, Ns, T), bounds=bounds)
252    Cu_opt, theta_opt = res.x
253    print(f"\nOptimal Cu = {Cu_opt:.4f},  θ = {theta_opt:.4f}")
254
255    # 7) Append to results.txt
256    out_path = 'bottleneckAnalysis/results.txt'
257    with open(out_path, 'a') as f:
258        f.write(f"===== Capacity Analysis for {SCENARIO_NAME} =====\n")
259        f.write(f"Months: {NUM_MONTHS} (T = {T} hours)\n")
260        f.write("Sample points (λ_i, N_i):\n")
261        for i, (λ, N) in enumerate(zip(lambdas, Ns), start=1):
262            f.write(f"  Point {i}: λ = {λ:.4f}, N = {N:.4f}\n")
263        if op_capacity is not None:
264            f.write(f"Operating capacity (Co): {op_capacity:.4f} vessels/hr\n")
265        f.write(f"Fitted ultimate capacity (Cu): {Cu_opt:.4f}\n")
266        f.write(f"Fitted exponent (θ): {theta_opt:.4f}\n\n")
267
268    # 8) Plot everything
269    λ_range = np.linspace(0, max(lambdas)*1.1, 300)
270    N_fit   = solve_N(λ_range, Cu_opt, T, theta_opt)
271
272    plt.figure(figsize=(6, 5))
273    plt.plot(λ_range, N_fit, label='ODE fit', linewidth=2, color='orange')
274
275    for i, (λ, N) in enumerate(zip(lambdas, Ns), start=1):
276        if i == 1:
277            plt.scatter(λ, N, s=20, label=f'Simulation runs', c='blue')
278        else:
279            plt.scatter(λ, N, s=20, c='blue')
280        # plt.annotate(f"sample point {i}",
281        #              (λ, N),
282        #              textcoords="offset points",
283        #              xytext=(5, 5),
284        #              fontsize=9)
285
286    plt.axhline(y=Cu_opt*T, linestyle='--', label='Ultimate capacity', color = 'magenta')
287    # find y corresponding to operating capacity
288
289    try:
290        y = solve_N([op_capacity], Cu_opt, T, theta_opt) if op_capacity is not None else None
291        plt.axvline(x=op_capacity, linestyle='--', label='Operating capacity', color ='green')
292        plt.axhline(y=y, linestyle='--', color='green')
293
294    except Exception as e:
295        print(f"Error occurred while finding y for operating capacity: {e}")
296
297    # set limits on y axis from 0 to 6500
298    plt.ylim(0, 7000)
299    plt.xlabel(r'$\lambda$', fontsize=14)
300    plt.ylabel(r'$N^{exits}(\lambda)$', fontsize=14)
301    plt.xticks(fontsize=12)
302    plt.yticks(fontsize=12)
303    plt.legend()
304    plt.tight_layout()
305    plt.savefig(f"bottleneckAnalysis/{SCENARIO_NAME}_capacity_analysis.pdf")
306    plt.close()
307
308    return Cu_opt, theta_opt
309# calculate_ultimate_capacity()
def calculate_mean_wait_time(arrival_rates, queue_lengths, input_mean_wait_time, logfilename):
 26def calculate_mean_wait_time(arrival_rates, queue_lengths, input_mean_wait_time, logfilename):
 27    """
 28    Calculate the mean wait time for a queueing system given arrival rates and queue lengths.
 29
 30    Args:
 31        arrival_rates (list): List of 3 arrival rates for the queues.
 32        queue_lengths (list): List of 3 queue lengths for the queues.
 33        input_mean_wait_time (float): The input mean wait time to compare against.
 34
 35    Returns:
 36        dict: A dictionary with calculated service rates, mean wait time, and relative error.
 37    """
 38    print("\nCapacity Analysis")
 39    print("Arrival Rates: ", arrival_rates)
 40    print("Queue Lengths: ", queue_lengths)
 41    with open(logfilename, 'a') as f:
 42        f.write("\nCapacity Analysis\n")
 43        f.write("Arrival Rates: " + str(arrival_rates) + "\n")
 44        f.write("Queue Lengths: " + str(queue_lengths) + "\n")
 45    arrival_rates = np.array(arrival_rates)
 46    queue_lengths = np.array(queue_lengths)
 47    eps = 1e-6
 48
 49    # Define the fitting function
 50    def fitting_function(mu, arrival_rates, queue_lengths):
 51        numerator = np.sum(arrival_rates / mu**2)
 52        denominator = 1 - (arrival_rates / mu).sum()
 53        return (numerator / denominator) * arrival_rates - queue_lengths
 54
 55    # Objective function for minimization
 56    def objective_function(mu, arrival_rates, queue_lengths):
 57        return np.sum(fitting_function(mu, arrival_rates, queue_lengths)**2)
 58
 59    # Constraint to ensure the stability condition
 60    def stability_constraint(mu, arrival_rates):
 61        return 1 - (arrival_rates / mu).sum()
 62
 63    # Initial guess for service rate
 64    initial_guess = [1.1 * float(arrival_rates.sum())]
 65
 66    # Define constraints
 67    constraints = {
 68        'type': 'ineq',
 69        'fun': stability_constraint,
 70        'args': (arrival_rates,)
 71    }
 72
 73    # Perform optimization
 74    result = minimize(
 75        objective_function,
 76        initial_guess,
 77        method='SLSQP',
 78        args=(arrival_rates, queue_lengths),
 79        constraints=constraints,
 80        options={'ftol': 1e-12, 'maxiter': 1000}
 81    )
 82
 83    if result.success:
 84        mu = result.x[0]
 85        print(f"Calculated service rate (mu): {mu:.2f}")
 86        with open(logfilename, 'a') as f:
 87            f.write(f"Calculated service rate (mu): {mu:.2f}\n")
 88    else:
 89        raise ValueError("Optimization failed: " + result.message)
 90
 91    # Calculate mean wait time
 92    numerator_W_q = np.sum(arrival_rates / mu**2)
 93    denominator_W_q = 1 - np.sum(arrival_rates / mu)
 94    calculated_mean_wait_time = numerator_W_q / denominator_W_q
 95
 96    print(f"Calculated mean wait time: {calculated_mean_wait_time:.2f}")
 97    print(f"Input mean wait time: {input_mean_wait_time:.2f}")
 98    with open(logfilename, 'a') as f:
 99        f.write(
100            f"Calculated mean wait time: {calculated_mean_wait_time:.2f}\n")
101        f.write(f"Input mean wait time: {input_mean_wait_time:.2f}\n")
102
103    # Calculate relative error
104    relative_error = (calculated_mean_wait_time -
105                      input_mean_wait_time) / input_mean_wait_time * 100
106    print(f"Relative error (%): {relative_error:.2f}")
107
108    # Return results as a dictionary
109    output = {
110        'Service Rate': mu,
111        'Calculated Mean Wait Time': calculated_mean_wait_time,
112        'Input Mean Wait Time': input_mean_wait_time,
113        'Relative Error (%)': relative_error
114    }
115
116    print("Capacity Analysis Complete")
117    print("Relative Error: ", relative_error)
118    with open(logfilename, 'a') as f:
119        f.write("Relative Error: " + str(relative_error) + "\n")
120    return output

Calculate the mean wait time for a queueing system given arrival rates and queue lengths.

Arguments:
  • arrival_rates (list): List of 3 arrival rates for the queues.
  • queue_lengths (list): List of 3 queue lengths for the queues.
  • input_mean_wait_time (float): The input mean wait time to compare against.
Returns:

dict: A dictionary with calculated service rates, mean wait time, and relative error.

def dN_dlambda(lambda_, T, N, Cu, theta):
123def dN_dlambda(lambda_, T, N, Cu, theta):
124    """
125    Differential equation for N with respect to lambda.
126    Args:
127        lambda_ (float): The current value of lambda.
128        T (float): Total time in hours.
129        N (float): Current value of N.
130        Cu (float): Ultimate capacity.
131        theta (float): Degree of congestion.
132    Returns:
133        float: The derivative of N with respect to lambda.
134    """
135    return T * (1 - (N / (T * Cu))**theta)

Differential equation for N with respect to lambda.

Arguments:
  • lambda_ (float): The current value of lambda.
  • T (float): Total time in hours.
  • N (float): Current value of N.
  • Cu (float): Ultimate capacity.
  • theta (float): Degree of congestion.
Returns:

float: The derivative of N with respect to lambda.

def solve_N(lambda_eval, Cu, T, theta):
138def solve_N(lambda_eval, Cu, T, theta):
139    """
140    Solve the ODE for N using the initial condition N(0) = 0.
141    Args:
142        lambda_eval (array): Array of lambda values for evaluation.
143        Cu (float): Ultimate capacity.
144        T (float): Total time in hours.
145        theta (float): Degree of congestion.
146    Returns:
147        array: Solution for N at the given lambda values.
148    """
149    sol = solve_ivp(
150        fun=lambda lam, N: dN_dlambda(lam, T, N, Cu, theta),
151        t_span=(0, lambda_eval[-1]),
152        y0=[0],
153        t_eval=lambda_eval,
154        method='RK45',
155        rtol=1e-8,
156        atol=1e-8
157    )
158    return sol.y[0]

Solve the ODE for N using the initial condition N(0) = 0.

Arguments:
  • lambda_eval (array): Array of lambda values for evaluation.
  • Cu (float): Ultimate capacity.
  • T (float): Total time in hours.
  • theta (float): Degree of congestion.
Returns:

array: Solution for N at the given lambda values.

def objective(params, constant_args):
161def objective(params, constant_args):
162    """
163    Objective function to minimize the difference between predicted and actual N values.
164    Args:
165        params (list): List containing Cu and theta.
166        constant_args (list): List containing a, b, c, d, and T.
167    Returns:
168        float: Sum of squared differences between predicted and actual N values.
169    """
170    Cu, theta = params
171    a, b, c, d, T = constant_args
172    lambda_vals = np.array([a, c])
173    N_vals = np.array([b, d])
174    N_pred = solve_N(lambda_vals, Cu, T, theta)
175    return np.sum((N_pred - N_vals)**2)

Objective function to minimize the difference between predicted and actual N values.

Arguments:
  • params (list): List containing Cu and theta.
  • constant_args (list): List containing a, b, c, d, and T.
Returns:

float: Sum of squared differences between predicted and actual N values.

def calculate_ultimate_capacity() -> tuple[float, float]:
177def calculate_ultimate_capacity() -> tuple[float, float]:
178    """
179    Auto‐discover all scenario logs (BASE + others), but only read
180    'Operating capacity:' from the BASE file. Fit dN/dλ = T*(1 - (N/(Cu*T))**θ),
181    write results, and plot every sample labeled.
182    """
183    # 1) Setup
184    T = NUM_MONTHS * 30 * 24
185    LOG_DIR = 'bottleneckAnalysis/logs'
186
187    # 2) Identify files
188    base_file = os.path.join(LOG_DIR, f'{SCENARIO_NAME}_BASE.txt')
189    pattern   = os.path.join(LOG_DIR, f'{SCENARIO_NAME}_*.txt')
190    all_files = sorted(glob.glob(pattern))
191    high_files = [f for f in all_files if f != base_file]
192
193    # 3) Read BASE file
194    lambdas = []
195    Ns      = []
196    Ns_actual = []
197    op_capacity = None
198
199
200    with open(base_file) as f:
201        lam0 = N0 = None
202        for line in f:
203            if line.startswith('Operating capacity:'):
204                op_capacity = float(line.split()[2])
205            elif line.startswith('Arrival rate:'):
206                lam0 = float(line.split()[2])
207            elif line.startswith('Exited ships:'):
208                N0 = float(line.split()[2])
209        if lam0 is None or N0 is None:
210            raise RuntimeError(f"Could not parse λ or N from BASE file {base_file}")
211        lambdas.append(lam0)
212        Ns.append(N0)
213        Ns_actual.append(N0)
214
215    # 4) Read every non-BASE file (only λ and N)
216    for fn in high_files:
217        lam = Ni = None
218        with open(fn) as f:
219            for line in f:
220                if line.startswith('Arrival rate:'):
221                    lam = float(line.split()[2])
222                elif line.startswith('Exited ships:'):
223                    Ni = float(line.split()[2])
224        if lam is not None and Ni is not None:
225            lambdas.append(lam)
226            Ns.append(Ni)
227            Ns_actual.append(Ni)
228        else:
229            print(f"Skipping {fn}: missing Arrival rate or Exited ships")
230
231        
232    lambdas = np.array(lambdas)
233    Ns      = np.array(Ns)
234
235    sort_idx = np.argsort(lambdas)
236    lambdas = lambdas[sort_idx]
237    Ns = Ns[sort_idx]
238
239    print("Discovered sample points:")
240    for i, (λ, N) in enumerate(zip(lambdas, Ns), start=1):
241        print(f"  sample point {i}: λ = {λ:.2f},  N = {N:.2f}")
242
243    # 5) Define multi‐point objective
244    def multi_obj(x, lam_arr, N_arr, T):
245        Cu, θ = x
246        N_pred = solve_N(lam_arr, Cu, T, θ)
247        return np.sum((N_pred - N_arr) ** 2)
248
249    # 6) Optimize
250    initial = [max(Ns)/T, 5.0]
251    bounds  = [(0.1, max(lambdas)), (0.1, 20.0)] #TODO: adjust bounds if needed
252    res = minimize(multi_obj, x0=initial, args=(lambdas, Ns, T), bounds=bounds)
253    Cu_opt, theta_opt = res.x
254    print(f"\nOptimal Cu = {Cu_opt:.4f},  θ = {theta_opt:.4f}")
255
256    # 7) Append to results.txt
257    out_path = 'bottleneckAnalysis/results.txt'
258    with open(out_path, 'a') as f:
259        f.write(f"===== Capacity Analysis for {SCENARIO_NAME} =====\n")
260        f.write(f"Months: {NUM_MONTHS} (T = {T} hours)\n")
261        f.write("Sample points (λ_i, N_i):\n")
262        for i, (λ, N) in enumerate(zip(lambdas, Ns), start=1):
263            f.write(f"  Point {i}: λ = {λ:.4f}, N = {N:.4f}\n")
264        if op_capacity is not None:
265            f.write(f"Operating capacity (Co): {op_capacity:.4f} vessels/hr\n")
266        f.write(f"Fitted ultimate capacity (Cu): {Cu_opt:.4f}\n")
267        f.write(f"Fitted exponent (θ): {theta_opt:.4f}\n\n")
268
269    # 8) Plot everything
270    λ_range = np.linspace(0, max(lambdas)*1.1, 300)
271    N_fit   = solve_N(λ_range, Cu_opt, T, theta_opt)
272
273    plt.figure(figsize=(6, 5))
274    plt.plot(λ_range, N_fit, label='ODE fit', linewidth=2, color='orange')
275
276    for i, (λ, N) in enumerate(zip(lambdas, Ns), start=1):
277        if i == 1:
278            plt.scatter(λ, N, s=20, label=f'Simulation runs', c='blue')
279        else:
280            plt.scatter(λ, N, s=20, c='blue')
281        # plt.annotate(f"sample point {i}",
282        #              (λ, N),
283        #              textcoords="offset points",
284        #              xytext=(5, 5),
285        #              fontsize=9)
286
287    plt.axhline(y=Cu_opt*T, linestyle='--', label='Ultimate capacity', color = 'magenta')
288    # find y corresponding to operating capacity
289
290    try:
291        y = solve_N([op_capacity], Cu_opt, T, theta_opt) if op_capacity is not None else None
292        plt.axvline(x=op_capacity, linestyle='--', label='Operating capacity', color ='green')
293        plt.axhline(y=y, linestyle='--', color='green')
294
295    except Exception as e:
296        print(f"Error occurred while finding y for operating capacity: {e}")
297
298    # set limits on y axis from 0 to 6500
299    plt.ylim(0, 7000)
300    plt.xlabel(r'$\lambda$', fontsize=14)
301    plt.ylabel(r'$N^{exits}(\lambda)$', fontsize=14)
302    plt.xticks(fontsize=12)
303    plt.yticks(fontsize=12)
304    plt.legend()
305    plt.tight_layout()
306    plt.savefig(f"bottleneckAnalysis/{SCENARIO_NAME}_capacity_analysis.pdf")
307    plt.close()
308
309    return Cu_opt, theta_opt

Auto‐discover all scenario logs (BASE + others), but only read 'Operating capacity:' from the BASE file. Fit dN/dλ = T(1 - (N/(CuT))**θ), write results, and plot every sample labeled.