//  [python]
def torch_solve_box_qp(Q, p, A, b, lb, ub, control):
    #######################################################################
    # Solve a QP in form:
    #   x_star =   argmin_x 0.5*x^TQx + p^Tx
    #             subject to Ax =  b
    #                        lb <= x <= ub
    # Q:  A (n_batch,n_x,n_x) SPD tensor
    # p:  A (n_batch,n_x,1) tensor.
    # A:  A (n_batch,n_eq, n_x) tensor.
    # b:  A (n_batch,n_eq) tensorr.
    # lb: A (n_batch,n_x,1) tensor
    # ub: A (n_batch,n_x,1) tensor
    # Returns: x_star:  A (n_batch,n_x,1) tensor
    #######################################################################

    # --- prep:
    n_batch = Q.shape[0]
    n_eq = get_ncon(A, dim=1)
    n_x = p.shape[1]
    p_norm = torch.linalg.norm(p, ord=torch.inf, dim=1, keepdim=True)
    any_eq = n_eq > 0
    any_lb = torch.max(lb) > -float("inf")
    any_ub = torch.min(ub) < float("inf")
    any_ineq = any_lb or any_ub

    # --- unpacking control:
    max_iters = control.get('max_iters', 10_000)
    eps_abs = control.get('eps_abs', 1e-3)
    eps_rel = control.get('eps_rel', 1e-3)
    check_solved = control.get('check_solved', max(round((n_x ** 0.5) / 10) * 10, 1))
    rho = control.get('rho', None)
    rho_min = control.get('rho_min', 1e-6)
    rho_max = control.get('rho_max', 1e6)
    adaptive_rho = control.get('adaptive_rho', False)
    adaptive_rho_tol = control.get('adaptive_rho_tol', 5)
    adaptive_rho_iter = control.get('adaptive_rho_iter', 100)
    adaptive_rho_iter = round(adaptive_rho_iter / check_solved) * check_solved
    adaptive_rho_max_iter = control.get('adaptive_max_iter', 1000)
    verbose = control.get('verbose', False)
    scale = control.get('scale', False)
    beta = control.get('beta')
    unroll = control.get('unroll', False)

    #  --- if not any inequality constraints - zero will solve in a single iteration.
    if not any_ineq:
        rho = 0

    # --- scaling and pre-conditioning:
    if scale:
        # --- Q and p scaling:
        Q_norm = torch.linalg.norm(Q, ord=torch.inf, dim=1)
        idx = Q_norm <= 0.0
        if torch.any(idx):
            Q_norm_min = torch.clamp(Q_norm.mean(dim=1), min=1e-6)
            Q_norm_clamp = torch.clamp(Q_norm, min=Q_norm_min.unsqueeze(1))
            Q_norm[idx] = Q_norm_clamp[idx]
        # --- compute D:
        D = torch.sqrt(1 / Q_norm)
        if beta is None:
            v = torch.quantile(D, q=torch.tensor([0.10, 0.90], dtype=D.dtype), dim=1)
            beta = 1 - v[[0]]/v[[1]]
            beta = beta.T
        D = (1 - beta) * D + beta * D.mean(dim=1, keepdim=True)
        Q = (D.unsqueeze(2) * Q * D.unsqueeze(1))
        p = D.unsqueeze(2) * p
        # --- A scaling:
        if any_eq:
            A = A * D.unsqueeze(1)
            A_norm = torch.linalg.norm(A, ord=torch.inf, dim=2)
            idx = A_norm <= 0.0
            if torch.any(idx):
                AD_norm_min = torch.clamp(A_norm.mean(dim=1), min=1e-6)
                AD_norm_clamp = torch.clamp(A_norm, min=AD_norm_min.unsqueeze(1))
                A_norm[idx] = AD_norm_clamp[idx]
            E = 1 / A_norm
            E = E.unsqueeze(2)
            A = E * A
            b = E * b
        D = D.unsqueeze(2)
        if any_ineq:
            lb = lb / D
            ub = ub / D
    else:
        D = 1.0
        E = 1.0

    # --- rho parameter selection:
    if rho is None:
        Q_norm = torch.linalg.matrix_norm(Q, keepdim=True)
        rho = Q_norm / n_x**0.5
        rho = torch.clamp(rho, min=rho_min, max=rho_max)

    # --- LU factorization:
    Id = torch.eye(n_x, dtype=p.dtype).unsqueeze(0)
    M = Q + rho * Id
    if any_eq:
        zero = torch.zeros(n_batch, n_eq, n_eq, dtype=p.dtype)
        M1 = torch.cat((M, torch.transpose(A, 1, 2)), 2)
        M2 = torch.cat((A, zero), 2)
        M = torch.cat((M1, M2), 1)

    with torch.no_grad():
        LU, P = torch.linalg.lu_factor(M)  # torch.linalg.lu_solve
    if unroll:
        LUModel = TorchLU(A=M, LU=LU, P=P)

    x = torch.zeros((n_batch, n_x, 1), dtype=p.dtype)
    z = torch.zeros((n_batch, n_x, 1), dtype=p.dtype)
    u = torch.zeros((n_batch, n_x, 1), dtype=p.dtype)
    # --- main loop
    for i in range(max_iters):
        # --- adaptive rho:
        if adaptive_rho and i % adaptive_rho_iter == 0 and 0 < i < adaptive_rho_max_iter:
            num = primal_error / tol_primal_rel_norm
            num = torch.clamp(num, min=eps_abs)
            denom = dual_error / tol_dual_rel_norm
            denom = torch.clamp(denom, min=eps_abs)
            ratio = (num / denom) ** 0.5
            update_rho_1 = (ratio > adaptive_rho_tol).sum() > 0
            update_rho_2 = (ratio < (1 / adaptive_rho_tol)).sum() > 0
            update_rho = update_rho_1.item() or update_rho_2.item()
            if update_rho:
                rho_new = rho * ratio
                rho = rho * is_optimal + rho_new * torch.logical_not(is_optimal)
                rho = torch.clamp(rho, min=rho_min, max=rho_max)
                # --- note we should be able to just update LU directly as only diagonal is changing.
                M[:, :n_x, :n_x] = Q + rho * Id
                with torch.no_grad():
                    LU, P = torch.linalg.lu_factor(M)
                if unroll:
                    LUModel = TorchLU(A=M, LU=LU, P=P)

        # --- projection to sub-space:
        if any_eq:
            rhs = torch.cat((-p + rho * (z - u), b), 1)
        else:
            rhs = -p + rho * (z - u)

        if unroll:
            xv = LUModel(A=M, b=rhs)
        else:
            xv = torch.linalg.lu_solve(LU, P, rhs)
        x = xv[:, :n_x, :]

        # --- proximal projection:
        z_prev = z
        z = x + u
        if any_lb:
            z = torch.maximum(z, lb)
        if any_ub:
            z = torch.minimum(z, ub)

        # --- update residuals
        r = x - z
        s = rho * (z - z_prev)
        # --- running sum of residuals or dual variables
        u = u + r

        # ---  primal and dual errors:
        if i % check_solved == 0:
            primal_error = torch.linalg.norm(D * r, ord=torch.inf, dim=1, keepdim=True)
            dual_error = torch.linalg.norm(D * s, ord=torch.inf, dim=1, keepdim=True)

            if verbose:
                primal_error_max = primal_error.max()
                dual_error_max = dual_error.max()
                print('iteration = {:d}'.format(i))
                print('|| primal_error|| = {:f}'.format(primal_error_max.item()))
                print('|| dual_error|| = {:f}'.format(dual_error_max.item()))

            x_norm = torch.linalg.norm(D * x, ord=torch.inf, dim=1, keepdim=True)
            z_norm = torch.linalg.norm(D * z, ord=torch.inf, dim=1, keepdim=True)
            y_norm = torch.linalg.norm(rho * D * u, ord=torch.inf, dim=1, keepdim=True)
            Qx_norm = torch.linalg.norm(torch.matmul(Q, x) / D, ord=torch.inf, dim=1, keepdim=True)

            tol_primal_rel_norm = torch.maximum(x_norm, z_norm)
            tol_primal = eps_abs + eps_rel * tol_primal_rel_norm
            tol_dual_rel_norm = torch.maximum(torch.maximum(y_norm, Qx_norm), p_norm)
            tol_dual = eps_abs + eps_rel * tol_dual_rel_norm

            # --- check for optimality
            do_stop_primal = primal_error < tol_primal
            do_stop_dual = dual_error < tol_dual
            is_optimal = torch.logical_and(do_stop_primal, do_stop_dual)
            if torch.all(is_optimal).item():
                break

    # --- reverse the scaling:
    x = D * x
    z = D * z
    u = u / D
    # --- residuals can be computed using x-z, z-z_prev
    lams = u * rho
    lams_neg = torch.threshold(-lams, 0, 0)
    lams_pos = torch.threshold(lams, 0, 0)
    lams = torch.cat((lams_neg, lams_pos), 1)

    nus = None
    if any_eq:
        nus = xv[:, -n_eq:, :] * E
    if unroll:
        sol = x
    else:
        sol = {"x": x, "z": z, "u": u, "lams": lams, "nus": nus, "rho": rho, "iter": i}

    return sol