feature: add miner load balancer.

This commit is contained in:
UpstreamData
2023-02-22 16:13:48 -07:00
parent bcfa807e12
commit ff54bea0ed
2 changed files with 350 additions and 0 deletions

View File

@@ -29,6 +29,20 @@ class APIError(Exception):
return "Incorrect API parameters."
class PhaseBalancingError(Exception):
def __init__(self, *args):
if args:
self.message = args[0]
else:
self.message = None
def __str__(self):
if self.message:
return f"{self.message}"
else:
return "Failed to balance phase."
class APIWarning(Warning):
def __init__(self, *args):
if args:

336
pyasic/load/__init__.py Normal file
View File

@@ -0,0 +1,336 @@
# ------------------------------------------------------------------------------
# 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
import logging
from typing import List, Union
# from pyasic.errors import PhaseBalancingError
from pyasic.errors import APIError
from pyasic.miners import AnyMiner
from pyasic.miners._backends import X19, BOSMiner, BTMiner
from pyasic.miners._types import S9, S17, T17, S17e, S17Plus, S17Pro, T17e, T17Plus
# from pprint import pprint as print
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, X19):
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"]
data = await asyncio.gather(
*[
self.miners[miner]["miner"].get_data(data_to_get=dp)
for miner in self.miners
]
)
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