# ------------------------------------------------------------------------------ # Copyright 2022 Upstream Data Inc - # - # Licensed under the Apache License, Version 2.0 (the "License"); - # you may not use this file except in compliance with the License. - # You may obtain a copy of the License at - # - # http://www.apache.org/licenses/LICENSE-2.0 - # - # Unless required by applicable law or agreed to in writing, software - # distributed under the License is distributed on an "AS IS" BASIS, - # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - # See the License for the specific language governing permissions and - # limitations under the License. - # ------------------------------------------------------------------------------ import asyncio from typing import List, Union from pyasic.errors import APIError from pyasic.miners import AnyMiner from pyasic.miners.backends import ( # noqa - Ignore access to _module AntminerModern, BOSMiner, BTMiner, ) from pyasic.miners.btc._types import ( # noqa - Ignore access to _module S9, S17, T17, S17e, S17Plus, S17Pro, T17e, T17Plus, ) FAN_USAGE = 50 # 50 W per fan class MinerLoadBalancer: """A load balancer for miners. Can be passed a list of `AnyMiner`, or a list of phases (lists of `AnyMiner`).""" def __init__( self, phases: Union[List[List[AnyMiner]], None] = None, ): self.phases = [_MinerPhaseBalancer(phase) for phase in phases] async def balance(self, wattage: int) -> int: phase_wattage = wattage // len(self.phases) setpoints = await asyncio.gather( *[phase.get_balance_setpoints(phase_wattage) for phase in self.phases] ) tasks = [] total_wattage = 0 for setpoint in setpoints: wattage_set = 0 for miner in setpoint: if setpoint[miner]["set"] == "on": wattage_set += setpoint[miner]["max"] tasks.append(setpoint[miner]["miner"].resume_mining()) elif setpoint[miner]["set"] == "off": wattage_set += setpoint[miner]["min"] tasks.append(setpoint[miner]["miner"].stop_mining()) else: wattage_set += setpoint[miner]["set"] tasks.append( setpoint[miner]["miner"].set_power_limit(setpoint[miner]["set"]) ) total_wattage += wattage_set await asyncio.gather(*tasks) return total_wattage class _MinerPhaseBalancer: def __init__(self, miners: List[AnyMiner]): self.miners = { str(miner.ip): { "miner": miner, "set": 0, "min": miner.fan_count * FAN_USAGE, } for miner in miners } for miner in miners: if ( isinstance(miner, BTMiner) and not (miner.model.startswith("M2") if miner.model else True) ) or isinstance(miner, BOSMiner): if isinstance(miner, S9): self.miners[str(miner.ip)]["tune"] = True self.miners[str(miner.ip)]["shutdown"] = True self.miners[str(miner.ip)]["max"] = 1400 elif True in [ isinstance(miner, x) for x in [S17, S17Plus, S17Pro, S17e, T17, T17Plus, T17e] ]: self.miners[str(miner.ip)]["tune"] = True self.miners[str(miner.ip)]["shutdown"] = True self.miners[str(miner.ip)]["max"] = 2400 else: self.miners[str(miner.ip)]["tune"] = True self.miners[str(miner.ip)]["shutdown"] = True self.miners[str(miner.ip)]["max"] = 3600 elif isinstance(miner, AntminerModern): self.miners[str(miner.ip)]["tune"] = False self.miners[str(miner.ip)]["shutdown"] = True self.miners[str(miner.ip)]["max"] = 3600 elif isinstance(miner, BTMiner): self.miners[str(miner.ip)]["tune"] = False self.miners[str(miner.ip)]["shutdown"] = True self.miners[str(miner.ip)]["max"] = 3600 if miner.model: if miner.model.startswith("M2"): self.miners[str(miner.ip)]["tune"] = False self.miners[str(miner.ip)]["shutdown"] = True self.miners[str(miner.ip)]["max"] = 2400 else: self.miners[str(miner.ip)]["tune"] = False self.miners[str(miner.ip)]["shutdown"] = False self.miners[str(miner.ip)]["max"] = 3600 self.miners[str(miner.ip)]["min"] = 3600 async def balance(self, wattage: int) -> int: setpoint = await self.get_balance_setpoints(wattage) wattage_set = 0 tasks = [] for miner in setpoint: if setpoint[miner]["set"] == "on": wattage_set += setpoint[miner]["max"] tasks.append(setpoint[miner]["miner"].resume_mining()) elif setpoint[miner]["set"] == "off": wattage_set += setpoint[miner]["min"] tasks.append(setpoint[miner]["miner"].stop_mining()) else: wattage_set += setpoint[miner]["set"] tasks.append( setpoint[miner]["miner"].set_power_limit(setpoint[miner]["set"]) ) await asyncio.gather(*tasks) return wattage_set async def get_balance_setpoints(self, wattage: int) -> dict: # gather data needed to optimize shutdown only miners dp = ["hashrate", "wattage", "wattage_limit", "hashboards"] data = await asyncio.gather( *[ self.miners[miner]["miner"].get_data(data_to_get=dp) for miner in self.miners ] ) pct_ideal_list = [d.percent_ideal for d in data] pct_ideal = 0 if len(pct_ideal_list) > 0: pct_ideal = sum(pct_ideal_list) / len(pct_ideal_list) wattage = round(wattage * 1 / (pct_ideal / 100)) for data_point in data: if (not self.miners[data_point.ip]["tune"]) and ( not self.miners[data_point.ip]["shutdown"] ): # cant do anything with it so need to find a semi-accurate power limit if not data_point.wattage_limit == -1: self.miners[data_point.ip]["max"] = int(data_point.wattage_limit) self.miners[data_point.ip]["min"] = int(data_point.wattage_limit) elif not data_point.wattage == -1: self.miners[data_point.ip]["max"] = int(data_point.wattage) self.miners[data_point.ip]["min"] = int(data_point.wattage) max_tune_wattage = sum( [miner["max"] for miner in self.miners.values() if miner["tune"]] ) max_shutdown_wattage = sum( [ miner["max"] for miner in self.miners.values() if (not miner["tune"]) and (miner["shutdown"]) ] ) max_other_wattage = sum( [ miner["max"] for miner in self.miners.values() if (not miner["tune"]) and (not miner["shutdown"]) ] ) min_tune_wattage = sum( [miner["min"] for miner in self.miners.values() if miner["tune"]] ) min_shutdown_wattage = sum( [ miner["min"] for miner in self.miners.values() if (not miner["tune"]) and (miner["shutdown"]) ] ) # min_other_wattage = sum([miner["min"] for miner in self.miners.values() if (not miner["tune"]) and (not miner["shutdown"])]) # make sure wattage isnt set too high if wattage > (max_tune_wattage + max_shutdown_wattage + max_other_wattage): raise APIError( f"Wattage setpoint is too high, setpoint: {wattage}W, max: {max_tune_wattage + max_shutdown_wattage + max_other_wattage}W" ) # PhaseBalancingError(f"Wattage setpoint is too high, setpoint: {wattage}W, max: {max_tune_wattage + max_shutdown_wattage + max_other_wattage}W") # should now know wattage limits and which can be tuned/shutdown # check if 1/2 max of the miners which can be tuned is low enough if (max_tune_wattage / 2) + max_shutdown_wattage + max_other_wattage < wattage: useable_wattage = wattage - (max_other_wattage + max_shutdown_wattage) useable_miners = len( [m for m in self.miners.values() if (m["set"] == 0) and (m["tune"])] ) if not useable_miners == 0: watts_per_miner = useable_wattage // useable_miners # loop through and set useable miners to wattage for miner in self.miners: if (self.miners[miner]["set"] == 0) and ( self.miners[miner]["tune"] ): self.miners[miner]["set"] = watts_per_miner elif self.miners[miner]["set"] == 0 and ( self.miners[miner]["shutdown"] ): self.miners[miner]["set"] = "on" # check if shutting down miners will help elif ( max_tune_wattage / 2 ) + min_shutdown_wattage + max_other_wattage < wattage: # tuneable inclusive since could be S9 BOS+ and S19 Stock, would rather shut down the S9, tuneable should always support shutdown useable_wattage = wattage - ( min_tune_wattage + max_other_wattage + min_shutdown_wattage ) for miner in sorted( [miner for miner in self.miners.values() if miner["shutdown"]], key=lambda x: x["max"], reverse=True, ): if miner["tune"]: miner_min_watt_use = miner["max"] / 2 useable_wattage -= miner_min_watt_use - miner["min"] if useable_wattage < 0: useable_wattage += miner_min_watt_use - miner["min"] self.miners[str(miner["miner"].ip)]["set"] = "off" else: miner_min_watt_use = miner["max"] useable_wattage -= miner_min_watt_use - miner["min"] if useable_wattage < 0: useable_wattage += miner_min_watt_use - miner["min"] self.miners[str(miner["miner"].ip)]["set"] = "off" new_shutdown_wattage = sum( [ miner["max"] if miner["set"] == 0 else miner["min"] for miner in self.miners.values() if miner["shutdown"] and not miner["tune"] ] ) new_tune_wattage = sum( [ miner["min"] for miner in self.miners.values() if miner["tune"] and miner["set"] == "off" ] ) useable_wattage = wattage - ( new_tune_wattage + max_other_wattage + new_shutdown_wattage ) useable_miners = len( [m for m in self.miners.values() if (m["set"] == 0) and (m["tune"])] ) if not useable_miners == 0: watts_per_miner = useable_wattage // useable_miners # loop through and set useable miners to wattage for miner in self.miners: if (self.miners[miner]["set"] == 0) and ( self.miners[miner]["tune"] ): self.miners[miner]["set"] = watts_per_miner elif self.miners[miner]["set"] == 0 and ( self.miners[miner]["shutdown"] ): self.miners[miner]["set"] = "on" # check if shutting down tuneable miners will do it elif min_tune_wattage + min_shutdown_wattage + max_other_wattage < wattage: # all miners that can be shutdown need to be for miner in self.miners: if (not self.miners[miner]["tune"]) and ( self.miners[miner]["shutdown"] ): self.miners[miner]["set"] = "off" # calculate wattage usable by tuneable miners useable_wattage = wattage - ( min_tune_wattage + max_other_wattage + min_shutdown_wattage ) # loop through miners to see how much is actually useable # sort the largest first for miner in sorted( [ miner for miner in self.miners.values() if miner["tune"] and miner["shutdown"] ], key=lambda x: x["max"], reverse=True, ): # add min to useable wattage since it was removed earlier, and remove 1/2 tuner max useable_wattage -= (miner["max"] / 2) - miner["min"] if useable_wattage < 0: useable_wattage += (miner["max"] / 2) - miner["min"] self.miners[str(miner["miner"].ip)]["set"] = "off" new_tune_wattage = sum( [ miner["min"] for miner in self.miners.values() if miner["tune"] and miner["set"] == "off" ] ) useable_wattage = wattage - ( new_tune_wattage + max_other_wattage + min_shutdown_wattage ) useable_miners = len( [m for m in self.miners.values() if (m["set"] == 0) and (m["tune"])] ) if not useable_miners == 0: watts_per_miner = useable_wattage // useable_miners # loop through and set useable miners to wattage for miner in self.miners: if (self.miners[miner]["set"] == 0) and ( self.miners[miner]["tune"] ): self.miners[miner]["set"] = watts_per_miner elif self.miners[miner]["set"] == 0 and ( self.miners[miner]["shutdown"] ): self.miners[miner]["set"] = "on" else: raise APIError( f"Wattage setpoint is too low, setpoint: {wattage}W, min: {min_tune_wattage + min_shutdown_wattage + max_other_wattage}W" ) # PhaseBalancingError(f"Wattage setpoint is too low, setpoint: {wattage}W, min: {min_tune_wattage + min_shutdown_wattage + max_other_wattage}W") return self.miners