import itertools
import math
import warnings
from typing import List, Optional, Dict, Set, Callable, Any, Iterator
import numpy as np
from scipy.optimize import lsq_linear, Bounds
from AFL.automation.mixcalc.PipetteAction import PipetteAction
from AFL.automation.mixcalc.Solution import Solution
from AFL.automation.mixcalc.BalanceDiagnosis import BalanceDiagnosis, FailureCode, FailureDetail
# --- Shared utility functions ---
def _extract_masses(solution: Solution, components: List[str], array: np.ndarray, unit: str = 'g') -> None:
if array is None:
array = np.zeros(len(components))
for i, component in enumerate(components):
if solution.contains(component):
array[i] = solution[component].mass.to(unit).magnitude
else:
array[i] = 0
def _extract_mass_fractions(stocks: List[Solution], components: List[str], matrix: np.ndarray) -> None:
for i, component in enumerate(components):
for j, stock in enumerate(stocks):
if stock.contains(component):
matrix[i, j] = stock.mass_fraction[component].to('').magnitude
else:
matrix[i, j] = 0
def _make_balanced_target(mass_transfers, target):
balanced_target = Solution(name="")
balanced_target.protocol = []
for stock, mass in mass_transfers.items():
measured = stock.measure_out(mass)
balanced_target = balanced_target + measured
balanced_target.protocol.append(
PipetteAction(
source=stock.location,
dest=target.location,
volume=measured.volume.to('ul').magnitude,
)
)
balanced_target.name = target.name + "-balanced"
for name, component in target:
if not balanced_target.contains(name):
balanced_target.components[name] = component.copy()
balanced_target[name].mass = '0.0 g'
return balanced_target
def _diagnose(
target,
transfers: np.ndarray,
differences: np.ndarray,
components: List[str],
stocks: List[Solution],
bounds: Bounds,
tol: float,
mass_fraction_matrix: np.ndarray,
target_masses: np.ndarray,
success: bool,
max_stock_fractions: Optional[np.ndarray] = None,
missing_component_mask: Optional[np.ndarray] = None,
) -> BalanceDiagnosis:
"""Analyse a balance result and return a structured diagnosis.
Checks are ordered from specific root causes to general symptoms so that
the most actionable codes appear first in the details list.
"""
component_errors = {comp: float(differences[i]) for i, comp in enumerate(components)}
total_target_mass = float(np.sum(target_masses))
n_stocks = len(stocks)
if success:
return BalanceDiagnosis(success=True, component_errors=component_errors)
details = []
if max_stock_fractions is None:
max_stock_fractions = np.max(mass_fraction_matrix, axis=1)
if missing_component_mask is None:
missing_component_mask = max_stock_fractions == 0.0
# --- Check 1: MISSING_STOCK_COMPONENT ---
# A required component is absent from every stock — cannot be achieved regardless
# of volumes or concentrations.
for i, comp in enumerate(components):
if target_masses[i] > 1e-12 and bool(missing_component_mask[i]):
details.append(FailureDetail(
code=FailureCode.MISSING_STOCK_COMPONENT,
description=(
f"Component '{comp}' is required by the target but is not present in any "
f"stock. Add a stock containing '{comp}'."
),
affected_components=[comp],
))
# --- Check 2: STOCK_CONCENTRATION_TOO_LOW ---
# The target mass fraction for a component exceeds the maximum mass fraction
# achievable from any single stock. Even using that stock at 100% of the
# mixture cannot satisfy the target.
if total_target_mass > 1e-12:
target_fracs = target_masses / total_target_mass
for i, comp in enumerate(components):
if target_masses[i] < 1e-12:
continue
max_stock_frac = float(max_stock_fractions[i])
target_frac = float(target_fracs[i])
if target_frac > max_stock_frac + 1e-9:
best_stock_idx = int(np.argmax(mass_fraction_matrix[i, :]))
best_stock_name = stocks[best_stock_idx].name
details.append(FailureDetail(
code=FailureCode.STOCK_CONCENTRATION_TOO_LOW,
description=(
f"Target mass fraction for '{comp}' ({target_frac:.4f}) exceeds the "
f"maximum achievable from any single stock ({max_stock_frac:.4f}). "
f"Prepare a more concentrated '{comp}' stock."
),
affected_components=[comp],
affected_stocks=[best_stock_name],
data={
"target_mass_fraction": target_frac,
"max_achievable_mass_fraction": max_stock_frac,
"best_available_stock": best_stock_name,
},
))
# --- Check 3: TARGET_OUTSIDE_REACHABLE_COMPOSITIONS ---
# Only fires when checks 1 & 2 did not already explain the infeasibility.
# Runs lsq_linear with only non-negativity bounds (no minimum-volume constraint)
# to determine whether the composition is geometrically achievable at all.
# A non-zero residual means the target lies outside the convex hull of stock
# compositions (the "pareto front" / reachable composition space).
if not any(
d.code in (FailureCode.MISSING_STOCK_COMPONENT, FailureCode.STOCK_CONCENTRATION_TOO_LOW)
for d in details
):
hull_bounds = Bounds(lb=[0.0] * n_stocks, ub=[np.inf] * n_stocks)
hull_result = lsq_linear(mass_fraction_matrix, target_masses, bounds=hull_bounds)
residual_norm = float(np.linalg.norm(hull_result.fun))
if total_target_mass > 1e-12 and (residual_norm / total_target_mass) > tol:
details.append(FailureDetail(
code=FailureCode.TARGET_OUTSIDE_REACHABLE_COMPOSITIONS,
description=(
f"Target composition cannot be achieved by any non-negative combination of "
f"available stocks (relative residual {residual_norm / total_target_mass:.4f} "
f"> tolerance {tol}). Consider adding new stocks or reformulating existing ones."
),
data={
"residual_norm": residual_norm,
"total_target_mass": total_target_mass,
"relative_residual": residual_norm / total_target_mass,
},
))
# --- Check 4: BELOW_MINIMUM_PIPETTE_VOLUME ---
# Fires in two related situations:
# (a) A stock was zeroed out (excluded) because the required transfer is below
# the minimum pipettable volume, removing its components from the balance.
# (b) A stock is active but pinned at its lower bound and still cannot deliver
# enough of a required component (the ideal transfer would be smaller than
# the minimum, so it is forced up, but the constraint is still binding).
failed_comps = {components[i] for i in range(len(components)) if abs(differences[i]) >= tol}
for idx, stock in enumerate(stocks):
mass_val = float(transfers[idx])
lb_val = float(bounds.lb[idx])
if mass_val == 0.0 and lb_val > 0.0:
# Case (a): stock excluded entirely.
affected = [
comp for j, comp in enumerate(components)
if mass_fraction_matrix[j, idx] > 0.0 and comp in failed_comps
]
if affected:
details.append(FailureDetail(
code=FailureCode.BELOW_MINIMUM_PIPETTE_VOLUME,
description=(
f"Stock '{stock.name}' was excluded because the required transfer is "
f"below the minimum pipette volume (lower bound {lb_val:.4g} g). "
f"Try reducing minimum_volume, increasing target total_mass, or "
f"reformulating stocks."
),
affected_components=affected,
affected_stocks=[stock.name],
data={"stock": stock.name, "lower_bound_g": lb_val, "reason": "excluded"},
))
elif lb_val > 0.0 and mass_val > 0.0 and abs(mass_val - lb_val) / lb_val < 0.01:
# Case (b): stock is used but pinned at its lower bound. Check
# whether any component it provides is still under-delivered.
under_delivered = [
comp for j, comp in enumerate(components)
if mass_fraction_matrix[j, idx] > 0.0
and comp in failed_comps
and differences[j] < -tol
]
if under_delivered:
details.append(FailureDetail(
code=FailureCode.BELOW_MINIMUM_PIPETTE_VOLUME,
description=(
f"Stock '{stock.name}' is constrained to its minimum pipette volume "
f"({lb_val:.4g} g) and cannot deliver sufficient "
f"'{', '.join(under_delivered)}'. "
f"Try reducing minimum_volume, increasing target total_mass, or "
f"using a more concentrated stock for {', '.join(under_delivered)}."
),
affected_components=under_delivered,
affected_stocks=[stock.name],
data={
"stock": stock.name,
"lower_bound_g": lb_val,
"transfer_g": mass_val,
"reason": "at_lower_bound",
},
))
# --- Check 5: UNWANTED_STOCK_COMPONENT ---
# A component the target wants zero of is nonzero in the balanced result
# because a stock needed for other components also contains it.
for i, comp in enumerate(components):
if target_masses[i] >= 1e-12:
continue
if abs(differences[i]) < tol:
continue
contaminating = [
stock.name
for idx, stock in enumerate(stocks)
if float(transfers[idx]) > 0.0 and mass_fraction_matrix[i, idx] > 0.0
]
if contaminating:
details.append(FailureDetail(
code=FailureCode.UNWANTED_STOCK_COMPONENT,
description=(
f"Component '{comp}' is not wanted (target = 0) but is introduced by "
f"{contaminating} which are needed for other components. "
f"Use a purer stock for those other components."
),
affected_components=[comp],
affected_stocks=contaminating,
data={"component": comp, "error": float(differences[i])},
))
# --- Check 6: TOLERANCE_EXCEEDED (catch-all) ---
# Lists every component whose relative error exceeds the tolerance.
tol_exceeded = [components[i] for i in range(len(components)) if abs(differences[i]) >= tol]
if tol_exceeded:
tol_exceeded_idx = [i for i in range(len(components)) if abs(differences[i]) >= tol]
details.append(FailureDetail(
code=FailureCode.TOLERANCE_EXCEEDED,
description=f"{len(tol_exceeded)} component(s) exceed {tol * 100:.1f}% tolerance.",
affected_components=tol_exceeded,
data={components[i]: float(differences[i]) for i in tol_exceeded_idx},
))
return BalanceDiagnosis(success=False, details=details, component_errors=component_errors)
def _iter_balance_candidates(
mass_fraction_matrix: np.ndarray,
target_masses: np.ndarray,
bounds: Bounds,
stocks: List[Solution],
near_bound_tol: float = 0.1,
) -> Iterator[np.ndarray]:
result = lsq_linear(mass_fraction_matrix, target_masses, bounds=bounds)
base_mass_transfer = np.array(result.x, dtype=float)
yield base_mass_transfer
# Identify stocks that the solver pushed to or near their lower bound.
# These are candidates for exclusion (zeroing out) since the solver
# wanted to use less than or close to the minimum transfer volume.
# Using active_mask == -1 alone is insufficient: the solver may place
# a stock slightly above its lower bound (e.g., to reduce H2O residual
# from a mostly-water stock) even when the target calls for none of
# that stock's solute. A relative tolerance catches these cases.
candidate_indices = [
i for i in range(len(stocks))
if result.active_mask[i] == -1
or (bounds.lb[i] > 0 and result.x[i] <= bounds.lb[i] * (1 + near_bound_tol))
]
# Try all subsets of candidate stocks and re-solve each
# reduced problem so the remaining stocks are properly re-optimized.
for r in range(1, len(candidate_indices) + 1):
for combination in itertools.combinations(candidate_indices, r):
exclude = set(combination)
keep_indices = [i for i in range(len(stocks)) if i not in exclude]
if not keep_indices:
continue
reduced_matrix = mass_fraction_matrix[:, keep_indices]
reduced_bounds = Bounds(
lb=[bounds.lb[i] for i in keep_indices],
ub=[bounds.ub[i] for i in keep_indices],
keep_feasible=False,
)
reduced_result = lsq_linear(reduced_matrix, target_masses, bounds=reduced_bounds)
adjusted_transfer = np.zeros(len(stocks), dtype=float)
for reduced_idx, stock_idx in enumerate(keep_indices):
adjusted_transfer[stock_idx] = float(reduced_result.x[reduced_idx])
yield adjusted_transfer
def _balance(
mass_fraction_matrix: np.ndarray,
target_masses: np.ndarray,
bounds: Bounds,
stocks: List[Solution],
near_bound_tol: float = 0.1,
) -> List[np.ndarray]:
return list(_iter_balance_candidates(mass_fraction_matrix, target_masses, bounds, stocks, near_bound_tol))
def _compute_differences(
target_masses: np.ndarray,
balanced_masses: np.ndarray,
total_target_mass: float,
) -> np.ndarray:
t = target_masses
b = balanced_masses
differences = np.zeros_like(t, dtype=float)
zero_t = t == 0
zero_b = b == 0
both_zero = zero_t & zero_b
# Target is zero but balanced is not
if total_target_mass > 0:
differences[zero_t & ~zero_b] = b[zero_t & ~zero_b] / total_target_mass
else:
differences[zero_t & ~zero_b] = 1.0
# Target is non-zero
nonzero_t = ~zero_t
differences[nonzero_t] = np.abs(b[nonzero_t] - t[nonzero_t]) / t[nonzero_t]
# Both zero already set to 0
differences[both_zero] = 0.0
return differences
def _make_transfer_dict(stocks: List[Solution], transfers: np.ndarray) -> Dict[Solution, str]:
return {stock: f'{float(mass)} g' for stock, mass in zip(stocks, transfers)}
# --- MassBalance Base Class ---
[docs]
class MassBalanceBase:
[docs]
def __init__(self):
self.balanced = []
self.bounds = None
@property
def components(self) -> Set[str]:
return self.stock_components.union(self.target_components)
@property
def stock_components(self) -> Set[str]:
raise NotImplementedError
@property
def target_components(self) -> Set[str]:
raise NotImplementedError
[docs]
def mass_fraction_matrix(self) -> np.ndarray:
components = list(self.components)
matrix = np.zeros((len(components), len(self.stocks)))
for i, component in enumerate(components):
for j, stock in enumerate(self.stocks):
if stock.contains(component):
matrix[i, j] = stock.mass_fraction[component].to('').magnitude
else:
matrix[i, j] = 0
return matrix
[docs]
def make_target_names(self, n_letters: int = 2, components=None, name_map: Optional[Dict] = None):
if components is None:
components = self.components
if name_map is None:
name_map = {}
for target in self.targets:
name = ''
for component in components:
comp = name_map.get(component, component[:n_letters])
name += f'{comp}{target.concentration[component].to("mg/ml").magnitude:.2f}'
target.name = name + '-mgml'
[docs]
def balance_report(self):
"""
Returns a json serializable structure that has all of the balanced targets
that can be reconstituted by the user back into solution objects.
"""
report = []
for item in self.balanced:
entry = {}
if item['target']:
entry['target'] = {
'name': item['target'].name,
'masses': {name: f"{c.mass.to('mg').magnitude} mg" for name, c in item['target']}
}
if item['balanced_target']:
entry['balanced_target'] = {
'name': item['balanced_target'].name,
'masses': {name: f"{c.mass.to('mg').magnitude} mg" for name, c in item['balanced_target']}
}
else:
entry['balanced_target'] = None
if item['transfers']:
entry['transfers'] = {stock.name: mass for stock, mass in item['transfers'].items()}
else:
entry['transfers'] = None
if item.get('difference') is not None:
entry['difference'] = item['difference'].tolist()
else:
entry['difference'] = None
if item.get('success') is not None:
entry['success'] = item['success']
else:
entry['success'] = None
entry['diagnosis'] = (
item['diagnosis'].to_dict() if item.get('diagnosis') is not None else None
)
entry['procedure_plan'] = item.get('procedure_plan')
report.append(entry)
return report
[docs]
def failure_summary(self) -> str:
"""Return a human-readable summary of all failed balance entries.
Returns an empty string if all balances succeeded or no balances have
been run yet.
"""
lines = []
for item in self.balanced:
diagnosis = item.get('diagnosis')
if diagnosis is not None and not diagnosis.success:
target_name = item['target'].name if item.get('target') else '<unknown>'
lines.append(f"Target: {target_name}")
lines.append(diagnosis.summary())
lines.append("")
return "\n".join(lines).rstrip()
def _minimum_transfer_volume(self):
return getattr(self, 'minimum_transfer_volume', getattr(self, 'minimum_volume', None))
def _bounds_for_stocks(self, stocks: List[Solution], minimum_transfer_volume) -> Bounds:
return Bounds(
lb=[stock.measure_out(minimum_transfer_volume).mass.to('g').magnitude for stock in stocks],
ub=[np.inf] * len(stocks),
keep_feasible=False,
)
@staticmethod
def _is_virtual_stock(stock: Solution) -> bool:
return bool(getattr(stock, '_is_virtual_dilution_stock', False))
@staticmethod
def _virtual_recipe(stock: Solution) -> Optional[Dict[str, Any]]:
return getattr(stock, '_dilution_recipe', None)
@staticmethod
def _find_primary_solvent_name(target: Solution) -> Optional[str]:
best_name = None
best_mass = None
for name, comp in target:
if not comp.is_solvent:
continue
m = comp.mass.to('g').magnitude
if best_name is None or m > best_mass:
best_name = name
best_mass = m
return best_name
def _select_diluent_stock(
self,
target: Solution,
source_stock: Solution,
candidate_stocks: List[Solution],
policy: str,
) -> Optional[Solution]:
solvent_name = self._find_primary_solvent_name(target)
viable = []
for stock in candidate_stocks:
if stock is source_stock:
continue
if solvent_name is not None and stock.contains(solvent_name):
viable.append((stock.mass_fraction[solvent_name].to('').magnitude, stock))
if viable:
viable.sort(key=lambda x: x[0], reverse=True)
return viable[0][1]
fallback = []
for stock in candidate_stocks:
if stock is source_stock:
continue
solvent_mass = sum(
comp.mass.to('g').magnitude for _, comp in stock.solvents
) if len(stock.solvents) > 0 else 0.0
fallback.append((solvent_mass, stock))
if not fallback:
return None
fallback.sort(key=lambda x: x[0], reverse=True)
return fallback[0][1]
def _make_virtual_dilution_stock(
self,
source_stock: Solution,
diluent_stock: Solution,
dilution_factor: int,
minimum_transfer_volume,
step_index: int,
) -> Solution:
source_batch = source_stock.measure_out(minimum_transfer_volume).mass.to('g').magnitude
diluent_batch = source_batch * max(1, dilution_factor - 1)
virtual_name = f"{source_stock.name}__d{int(dilution_factor)}x_s{step_index}"
intermediate_id = f"intermediate::{virtual_name}"
source_measured = source_stock.measure_out(f"{source_batch} g")
diluent_measured = diluent_stock.measure_out(f"{diluent_batch} g")
virtual = source_measured + diluent_measured
virtual.name = virtual_name
virtual.location = f"@intermediate:{intermediate_id}"
virtual._is_virtual_dilution_stock = True
virtual._dilution_recipe = {
'intermediate_id': intermediate_id,
'source_stock_name': source_stock.name,
'source_location': source_stock.location,
'diluent_stock_name': diluent_stock.name,
'diluent_location': diluent_stock.location,
'dilution_factor': int(dilution_factor),
'batch_source_mass_g': float(source_batch),
'batch_diluent_mass_g': float(diluent_batch),
'batch_total_mass_g': float(source_batch + diluent_batch),
}
return virtual
def _build_procedure_plan(
self,
target: Solution,
stocks: List[Solution],
transfers: np.ndarray,
enabled: bool,
) -> Dict[str, Any]:
transfer_items = []
dilution_stages = []
intermediate_ids = []
for idx, stock in enumerate(stocks):
mass_g = float(transfers[idx])
if mass_g <= 0:
continue
measured = stock.measure_out(f"{mass_g} g")
transfer_items.append({
'source_stock_name': stock.name,
'source_location': stock.location,
'required_mass_g': mass_g,
'required_volume_ul': float(measured.volume.to('ul').magnitude),
})
recipe = self._virtual_recipe(stock)
if recipe is None:
continue
batches = int(math.ceil(mass_g / max(recipe['batch_total_mass_g'], 1e-12)))
dilution_stages.append({
'stage_type': 'dilution',
'intermediate_id': recipe['intermediate_id'],
'destination_token': f"@intermediate:{recipe['intermediate_id']}",
'source_stock_name': recipe['source_stock_name'],
'source_location': recipe['source_location'],
'diluent_stock_name': recipe['diluent_stock_name'],
'diluent_location': recipe['diluent_location'],
'dilution_factor': int(recipe['dilution_factor']),
'batches': batches,
'total_source_mass_g': float(recipe['batch_source_mass_g'] * batches),
'total_diluent_mass_g': float(recipe['batch_diluent_mass_g'] * batches),
})
intermediate_ids.append(recipe['intermediate_id'])
stages = dilution_stages + [{
'stage_type': 'final_mix',
'destination_location': target.location,
'transfers': transfer_items,
}]
return {
'enabled': bool(enabled),
'required_intermediate_targets': len(intermediate_ids),
'intermediate_ids': intermediate_ids,
'stages': stages,
}
def _solve_single_target(
self,
target: Solution,
target_masses: np.ndarray,
components: List[str],
tol: float,
enable_multistep_dilution: bool,
multistep_max_steps: int,
multistep_diluent_policy: str,
minimum_transfer_volume,
) -> Dict[str, Any]:
planning_stocks = list(self.stocks)
max_rounds = int(multistep_max_steps) if enable_multistep_dilution else 0
rounds_completed = 0
while True:
bounds = self._bounds_for_stocks(planning_stocks, minimum_transfer_volume)
mfm = np.zeros((len(components), len(planning_stocks)))
_extract_mass_fractions(planning_stocks, components, mfm)
max_stock_fractions = np.max(mfm, axis=1) if len(planning_stocks) > 0 else np.zeros(len(components))
missing_component_mask = max_stock_fractions == 0.0
best_candidate = None
best_score = None
any_success = False
total_target_mass = float(np.sum(target_masses))
for transfers in _iter_balance_candidates(mfm, target_masses, bounds, planning_stocks):
balanced_masses = mfm @ transfers
differences = _compute_differences(
target_masses=target_masses,
balanced_masses=balanced_masses,
total_target_mass=total_target_mass,
)
score = float(np.sum(np.abs(differences)))
success = bool(np.all(np.abs(differences) < tol))
if best_candidate is None or score < best_score:
best_candidate = {
'difference': differences,
'transfers': transfers,
'success': success,
}
best_score = score
if success:
any_success = True
if best_score == 0.0:
break
if best_candidate is None:
raise RuntimeError("Mass balance produced no candidates; this should not happen.")
if any_success or (not enable_multistep_dilution) or rounds_completed >= max_rounds:
diagnosis = _diagnose(
target=target,
transfers=best_candidate['transfers'],
differences=best_candidate['difference'],
components=components,
stocks=planning_stocks,
bounds=bounds,
tol=tol,
mass_fraction_matrix=mfm,
target_masses=target_masses,
success=best_candidate['success'],
max_stock_fractions=max_stock_fractions,
missing_component_mask=missing_component_mask,
)
return {
'stocks': planning_stocks,
'candidate': best_candidate,
'any_success': any_success,
'diagnosis': diagnosis,
}
unconstrained = lsq_linear(
mfm,
target_masses,
bounds=Bounds(lb=[0.0] * len(planning_stocks), ub=[np.inf] * len(planning_stocks)),
)
ideal = np.array(unconstrained.x, dtype=float)
added = False
new_virtual_stocks = []
for i, stock in enumerate(planning_stocks):
if self._is_virtual_stock(stock):
continue
lower_bound = float(bounds.lb[i])
ideal_mass = float(ideal[i])
if ideal_mass <= 0.0 or ideal_mass >= lower_bound:
continue
factor = max(2, int(math.ceil(lower_bound / max(ideal_mass, 1e-12))))
diluent = self._select_diluent_stock(
target=target,
source_stock=stock,
candidate_stocks=self.stocks,
policy=multistep_diluent_policy,
)
if diluent is None:
continue
virtual = self._make_virtual_dilution_stock(
source_stock=stock,
diluent_stock=diluent,
dilution_factor=factor,
minimum_transfer_volume=minimum_transfer_volume,
step_index=rounds_completed + 1,
)
if any(s.name == virtual.name for s in planning_stocks + new_virtual_stocks):
continue
new_virtual_stocks.append(virtual)
added = True
if not added:
diagnosis = _diagnose(
target=target,
transfers=best_candidate['transfers'],
differences=best_candidate['difference'],
components=components,
stocks=planning_stocks,
bounds=bounds,
tol=tol,
mass_fraction_matrix=mfm,
target_masses=target_masses,
success=best_candidate['success'],
max_stock_fractions=max_stock_fractions,
missing_component_mask=missing_component_mask,
)
return {
'stocks': planning_stocks,
'candidate': best_candidate,
'any_success': any_success,
'diagnosis': diagnosis,
}
planning_stocks.extend(new_virtual_stocks)
rounds_completed += 1
[docs]
def balance(
self,
tol=0.05,
return_report=False,
progress_callback: Optional[Callable[..., Any]] = None,
enable_multistep_dilution: bool = False,
multistep_max_steps: int = 2,
multistep_diluent_policy: str = 'primary_solvent',
):
if any([stock.location is None for stock in self.stocks]):
raise ValueError("Some stocks don't have a location specified. This should be specified when the stocks are instantiated")
self._set_bounds()
components = list(self.components)
target_mass_matrix = np.zeros((len(self.targets), len(components)))
for idx, target in enumerate(self.targets):
_extract_masses(target, components, array=target_mass_matrix[idx])
minimum_transfer_volume = self._minimum_transfer_volume()
if minimum_transfer_volume is None:
enable_multistep_dilution = False
if progress_callback is not None:
progress_callback(
stage='start',
completed=0,
total=len(self.targets),
target_idx=None,
target_name=None,
)
self.balanced = []
for target_idx, target in enumerate(self.targets):
if progress_callback is not None:
progress_callback(
stage='target_start',
completed=target_idx,
total=len(self.targets),
target_idx=target_idx,
target_name=target.name,
)
target_masses = target_mass_matrix[target_idx]
solved = self._solve_single_target(
target=target,
target_masses=target_masses,
components=components,
tol=tol,
enable_multistep_dilution=bool(enable_multistep_dilution),
multistep_max_steps=int(multistep_max_steps),
multistep_diluent_policy=str(multistep_diluent_policy),
minimum_transfer_volume=minimum_transfer_volume,
)
best_candidate = solved['candidate']
any_success = solved['any_success']
diagnosis = solved['diagnosis']
planning_stocks = solved['stocks']
if not any_success:
warnings.warn(f'No suitable mass balance found for {target.name}\n')
self.balanced.append({
'target': target,
'balanced_target': None,
'transfers': None,
'difference': None,
'success': False,
'diagnosis': diagnosis,
'procedure_plan': self._build_procedure_plan(
target=target,
stocks=planning_stocks,
transfers=best_candidate['transfers'],
enabled=bool(enable_multistep_dilution),
),
})
else:
transfers_dict = _make_transfer_dict(planning_stocks, best_candidate['transfers'])
balanced_target = _make_balanced_target(transfers_dict, target)
self.balanced.append({
'target': target,
'balanced_target': balanced_target,
'transfers': transfers_dict,
'difference': best_candidate['difference'],
'success': best_candidate['success'],
'diagnosis': diagnosis,
'procedure_plan': self._build_procedure_plan(
target=target,
stocks=planning_stocks,
transfers=best_candidate['transfers'],
enabled=bool(enable_multistep_dilution),
),
})
if progress_callback is not None:
progress_callback(
stage='target_end',
completed=target_idx + 1,
total=len(self.targets),
target_idx=target_idx,
target_name=target.name,
success=bool(any_success),
)
if progress_callback is not None:
progress_callback(
stage='done',
completed=len(self.targets),
total=len(self.targets),
target_idx=None,
target_name=None,
)
if return_report:
return self.balance_report()
def _set_bounds(self):
raise NotImplementedError