-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathutils.py
More file actions
305 lines (267 loc) · 15 KB
/
Copy pathutils.py
File metadata and controls
305 lines (267 loc) · 15 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
# minesimulator/utils.py
import numpy as np
import json
from dataclasses import asdict, is_dataclass
from datetime import datetime
from typing import Tuple, Deque # Added Deque for type hinting if needed elsewhere
import math
from collections import deque # Import deque for type checking in encoder if needed
# --- Simulation Constants ---
DEFAULT_MAX_SOLAR_KW = 60.0
DEFAULT_DAY_LENGTH_SECONDS = 180.0 # Default simulation day length
DEFAULT_BATTERY_CAPACITY_KWH = 20.0
DEFAULT_NUM_MINERS = 20
DEFAULT_CONTROLLER_TYPE = "Advanced" # Default controller logic
# --- Base Load Constants ---
BASE_LOAD_INVERTER_NETWORK_KW = 0.400 # Constant load for site infrastructure (inverters, network)
BASE_LOAD_FAN_KW_PER_MINER = 0.015 # Additional base load per *active* miner (e.g., container fans)
# --- Miner Power & Efficiency Constants (Based on Readme S19j Pro Example) ---
# Define the operational range and nominal point for miners
MINER_NOMINAL_POWER_KW = 3.068 # Readme: 3068 W (Default Power)
MINER_MAX_POWER_KW = 3.568 # Readme: ~3568 W (Overclock +500W)
MINER_MIN_POWER_KW = 2.568 # Readme: ~2568 W (Underclock -500W) - Must be > 0 for miner to be 'on'
MINER_OFF_POWER_KW = 0.01 # Very small power draw when logically 'off' but PSU powered (e.g., standby)
# --- Corresponding Hashrates at Key Power Levels (for efficiency curve) ---
# Values from Readme (ANTMINER S19j Pro + Braiins)
HASHRATE_AT_MIN_KW_THS = 95.0 # TH/s at MINER_MIN_POWER_KW
HASHRATE_AT_NOMINAL_KW_THS = 104.0 # TH/s at MINER_NOMINAL_POWER_KW
HASHRATE_AT_MAX_KW_THS = 118.0 # TH/s at MINER_MAX_POWER_KW
# --- Controller Thresholds & Parameters ---
ADVANCED_CONTROLLER_TICK_RATE_SECONDS = 5 # How often the advanced controller logic runs
ADVANCED_SOC_TARGET_HIGH = 0.98 # Upper end of preferred battery SoC float range
ADVANCED_SOC_TARGET_LOW = 0.95 # Lower end of preferred battery SoC float range / buffer activation threshold
SOLAR_MATCHING_MIN_SOC_THRESHOLD = 0.90 # Minimum SoC required to prioritize solar matching over charging
ADVANCED_MIN_BATT_DISCHARGE_SOC = 0.20 # Absolute minimum SoC allowed for battery discharge
ADVANCED_POWER_BUFFER_KW = 0.5 # Base power buffer (kW) reserved below available solar
ADVANCED_SOLAR_TREND_WINDOW_SECONDS = 60 # Time window for calculating solar trend
MINER_POWER_CHANGE_COOLDOWN_SECONDS = 30 # Min simulated time between power limit changes for a single miner
CONTROLLER_POWER_STEP_KW = 0.1 # Step size (kW) for greedy power allocation in controller
# --- Voltage Simulation Constants ---
LOW_VOLTAGE_SHUTDOWN_V = 48.0 # Voltage threshold below which miners are forced off
BATTERY_NOMINAL_VOLTAGE = 52.0 # Nominal voltage of the battery system
BATTERY_VOLTAGE_RANGE = 8.0 # Total voltage swing from min SoC to max SoC
# --- Battery Wear Constants ---
INITIAL_BATTERY_HEALTH = 1.0 # Starting health factor (1.0 = 100%)
WEAR_FACTOR_PER_CYCLE = 0.0001 # Health reduction per equivalent full charge/discharge cycle (e.g., 0.01% = 10000 cycles)
WEAR_FACTOR_PER_HOUR = 0.00001 # Health reduction per hour of simulation time (calendar aging)
# --- GUI Constants ---
PLOT_DATA_POINTS = 600 # Max number of data points to display on the plot
TIMER_INTERVAL_MS = 100 # GUI update interval in milliseconds
# --- Miner Operational Status Strings ---
MINER_STATUS_OFF = "OFF" # Miner power limit is at or below OFF threshold
MINER_STATUS_RUNNING = "RUNNING" # Miner operating normally above OFF threshold
MINER_STATUS_VOLT_LIMITED = "VOLT LIMITED" # Running, but voltage limit preventing requested power
MINER_STATUS_PWR_LIMITED = "PWR LIMITED" # Running, but global power limit preventing requested power
# --- Helper Functions ---
def clamp(value, min_val, max_val):
"""Clamps a value between a minimum and maximum."""
return max(min_val, min(value, max_val))
def simulate_voltage(soc: float, nominal_v: float = BATTERY_NOMINAL_VOLTAGE, v_range: float = BATTERY_VOLTAGE_RANGE) -> float:
"""Simulates battery voltage based on State of Charge (SoC)."""
min_v = nominal_v - v_range / 2 # Voltage at 0% SoC (approx)
max_v = nominal_v + v_range / 2 # Voltage at 100% SoC (approx)
# Simple linear model: Voltage = min_voltage + (SoC * voltage_range)
voltage = min_v + clamp(soc, 0.0, 1.0) * v_range
# Allow slight overshoot/undershoot for checks near limits
return clamp(voltage, min_v - 1.0, max_v + 1.0)
# --- REFINED: Hashrate Calculation (Internal Helper) ---
def _calculate_hashrate(power_kw: float) -> float:
"""
Internal helper to calculate hashrate (MH/s) based on power (kW).
Uses piecewise linear interpolation based on key points from readme example.
"""
# Ensure power is within the valid operational range [OFF, MAX]
power_kw = clamp(power_kw, MINER_OFF_POWER_KW, MINER_MAX_POWER_KW)
# If power is below the minimum operational threshold, hashrate is 0
if power_kw < MINER_MIN_POWER_KW:
return 0.0
# Define the key points (Power in kW, Hashrate in TH/s)
points = [
(MINER_MIN_POWER_KW, HASHRATE_AT_MIN_KW_THS),
(MINER_NOMINAL_POWER_KW, HASHRATE_AT_NOMINAL_KW_THS),
(MINER_MAX_POWER_KW, HASHRATE_AT_MAX_KW_THS)
]
# --- Piecewise Linear Interpolation ---
hashrate_ths = 0.0
# Case 1: Power between MIN and NOMINAL
if power_kw <= MINER_NOMINAL_POWER_KW:
power_range = points[1][0] - points[0][0] # NOMINAL_KW - MIN_KW
hash_range = points[1][1] - points[0][1] # NOMINAL_THS - MIN_THS
if power_range > 1e-6: # Avoid division by zero
# Calculate the fraction of the way power_kw is within this range
power_fraction = (power_kw - points[0][0]) / power_range
# Interpolate hashrate
hashrate_ths = points[0][1] + (power_fraction * hash_range)
else: # If min and nominal power are the same, use the corresponding hashrate
hashrate_ths = points[0][1]
# Case 2: Power between NOMINAL and MAX
else: # power_kw > MINER_NOMINAL_POWER_KW
power_range = points[2][0] - points[1][0] # MAX_KW - NOMINAL_KW
hash_range = points[2][1] - points[1][1] # MAX_THS - NOMINAL_THS
if power_range > 1e-6: # Avoid division by zero
# Calculate the fraction of the way power_kw is within this range
power_fraction = (power_kw - points[1][0]) / power_range
# Interpolate hashrate
hashrate_ths = points[1][1] + (power_fraction * hash_range)
else: # If nominal and max power are the same, use the corresponding hashrate
hashrate_ths = points[1][1]
# Convert TH/s to MH/s (1 TH/s = 1,000,000 MH/s)
hashrate_mh_s = hashrate_ths * 1_000_000
return max(0.0, hashrate_mh_s) # Ensure non-negative hashrate
# --- Efficiency Calculation (Hashes per Joule) ---
def get_hashes_per_joule(power_kw: float) -> float:
"""
Calculates miner efficiency (hashes per joule or MH/J) based on power level (kW).
Uses the refined _calculate_hashrate helper.
"""
# If power is effectively off, efficiency is zero
if power_kw < MINER_MIN_POWER_KW:
return 0.0
# Calculate hashrate using the helper function
hashrate_mh_s = _calculate_hashrate(power_kw)
# Convert power from kW to Watts (Joules/second)
power_watts = power_kw * 1000.0
if power_watts <= 0: # Should not happen if power_kw >= MINER_MIN_POWER_KW > 0
return 0.0
# Efficiency (MH/J) = Hashrate (MH/s) / Power (J/s)
# Note: hashrate_mh_s is already in MegaHashes/Second
efficiency_mh_j = hashrate_mh_s / power_watts
return efficiency_mh_j
# --- Marginal Efficiency Calculation ---
def get_marginal_efficiency_delta(current_power_kw: float, power_step_kw: float = CONTROLLER_POWER_STEP_KW) -> Tuple[float, float]:
"""
Calculates the marginal efficiency (change in hashrate per change in power)
for a small increase and decrease in power around the current level.
Args:
current_power_kw: The current operating power level of the miner (kW).
power_step_kw: The small change in power to evaluate (e.g., 0.1 kW).
Returns:
Tuple[float, float]: (marginal_eff_increase, marginal_eff_decrease)
in MH/s per kW. Returns -inf if increase/decrease is not possible.
Efficiency for decrease is negated internally and returned as a
positive value representing hashrate loss *avoided* per kW removed.
Therefore, for both increase and decrease, a higher positive number is better.
"""
# Clamp current power to valid operational range before calculation
current_power_kw = clamp(current_power_kw, MINER_OFF_POWER_KW, MINER_MAX_POWER_KW)
current_hashrate_mhs = _calculate_hashrate(current_power_kw)
marginal_eff_increase = -float('inf')
marginal_eff_decrease = -float('inf')
# --- Efficiency for Increase ---
power_increase_target = current_power_kw + power_step_kw
# Check if the target power is within the max limit (allowing for small float tolerance)
if power_increase_target <= MINER_MAX_POWER_KW + 1e-6:
# Clamp the actual power level used for calculation to the max limit
power_increase_actual = min(power_increase_target, MINER_MAX_POWER_KW)
# Ensure the actual increase step is positive
actual_power_step_inc = power_increase_actual - current_power_kw
if actual_power_step_inc > 1e-6: # Only calculate if there's a real increase
# Handle turning ON: If currently OFF, the effective step starts from MIN_POWER
if current_power_kw < MINER_MIN_POWER_KW:
effective_start_power = MINER_MIN_POWER_KW
effective_start_hashrate = _calculate_hashrate(effective_start_power)
effective_power_step = power_increase_actual - effective_start_power
new_hashrate_inc = _calculate_hashrate(power_increase_actual)
delta_hashrate_inc = new_hashrate_inc - effective_start_hashrate
if effective_power_step > 1e-6:
marginal_eff_increase = delta_hashrate_inc / effective_power_step
else: # If increase lands exactly on MIN_POWER
marginal_eff_increase = delta_hashrate_inc / actual_power_step_inc # Use actual step
else: # Miner is already ON
new_hashrate_inc = _calculate_hashrate(power_increase_actual)
delta_hashrate_inc = new_hashrate_inc - current_hashrate_mhs
marginal_eff_increase = delta_hashrate_inc / actual_power_step_inc
# --- Efficiency for Decrease ---
power_decrease_target = current_power_kw - power_step_kw
# Check if the current power is actually above the minimum operational level
if current_power_kw > MINER_MIN_POWER_KW - 1e-6:
# Determine the actual power level after decrease, considering MIN_POWER and OFF thresholds
power_decrease_actual = max(MINER_OFF_POWER_KW, power_decrease_target)
if power_decrease_actual < MINER_MIN_POWER_KW:
power_decrease_actual = MINER_OFF_POWER_KW # Snap to OFF if below MIN
# Ensure the actual decrease step is positive
actual_power_step_dec = current_power_kw - power_decrease_actual
if actual_power_step_dec > 1e-6: # Only calculate if there's a real decrease
# Calculate the hashrate at the new lower power level
new_hashrate_dec = _calculate_hashrate(power_decrease_actual)
delta_hashrate_dec = new_hashrate_dec - current_hashrate_mhs # This will be negative or zero
# Marginal efficiency for decrease = -(delta_hashrate / delta_power)
# This represents the hashrate loss *avoided* per kW removed. Higher is better.
marginal_eff_decrease = -delta_hashrate_dec / actual_power_step_dec
return marginal_eff_increase, marginal_eff_decrease
# --- JSON Serialization ---
class EnhancedJSONEncoder(json.JSONEncoder):
"""Handles dataclasses, numpy types, and datetime for JSON serialization."""
def default(self, o):
if is_dataclass(o):
return asdict(o)
# Handle various numpy integer types
if isinstance(o, (np.int_, np.intc, np.intp, np.int8,
np.int16, np.int32, np.int64, np.uint8,
np.uint16, np.uint32, np.uint64)):
return int(o)
# Handle various numpy float types
elif isinstance(o, (np.float_, np.float16, np.float32,
np.float64)):
return float(o)
# Handle numpy arrays (convert to list)
elif isinstance(o, np.ndarray):
return o.tolist()
# Handle datetime objects (convert to ISO format string)
elif isinstance(o, datetime):
return o.isoformat()
# Handle numpy boolean type
elif isinstance(o, np.bool_):
return bool(o)
# Handle deque (convert to list) - Though usually handled before saving
elif isinstance(o, deque):
return list(o)
# Let the base class default method raise the TypeError for unsupported types
return super().default(o)
# --- Save/Load Simulation State ---
def save_sim_state(filename: str, state: dict, gui_settings: dict) -> bool:
"""Saves simulation state and GUI settings to a JSON file."""
data_to_save = {
'save_format_version': '1.1', # Add a version number
'timestamp': datetime.now().isoformat(),
'simulation_state': state, # state should already be a dict from get_state_dict
'gui_settings': gui_settings
}
try:
with open(filename, 'w') as f:
# Use the enhanced encoder and pretty-print with indent
json.dump(data_to_save, f, cls=EnhancedJSONEncoder, indent=4)
print(f"Simulation state saved to {filename}")
return True
except IOError as e:
print(f"Error saving state file '{filename}': {e}")
return False
except TypeError as e:
print(f"Error serializing state data: {e}")
traceback.print_exc() # Print traceback for serialization errors
return False
except Exception as e:
print(f"An unexpected error occurred during saving: {e}")
traceback.print_exc()
return False
def load_sim_state(filename: str) -> dict | None:
"""Loads simulation state and GUI settings from a JSON file."""
try:
with open(filename, 'r') as f:
loaded_data = json.load(f)
# Basic validation: Check if essential keys exist
if 'simulation_state' not in loaded_data or 'gui_settings' not in loaded_data:
print(f"Error: Invalid save file format in '{filename}'. Missing essential keys.")
return None
print(f"Simulation state loaded from {filename}")
return loaded_data
except FileNotFoundError:
print(f"Error: Save file not found: {filename}")
return None
except json.JSONDecodeError as e:
print(f"Error decoding JSON from {filename}: {e}")
return None
except Exception as e:
print(f"An unexpected error occurred during loading: {e}")
traceback.print_exc()
return None