Skip to content

fluxopt.math.results

Turn an lpspec result into a :class:~fluxopt.results.Result.

Both lanes answer with the same object, which is what lets the parity test compare answers rather than shapes — and what makes deleting the linopy builder a deletion rather than a migration.

The program is bound on the labels the element layer used, so the answer already carries them. The work is a rename, one program variable to one Var name, and putting entities back in declaration order.

Functions:

Name Description
objective_weights

The weights the objective is actually minimised with.

to_result

Build a :class:~fluxopt.results.Result from a solved lpspec model.

objective_weights

objective_weights(
    data: ModelData, objective: str | dict[str, float]
) -> dict[str, float]

The weights the objective is actually minimised with.

Not simply what the caller passed: the built-in penalty effect is added at 1.0 unless the caller named it, which is what makes a soft constraint cost something. Both lanes have to agree here or their objectives are different numbers for the same request — and Result.objective_weights is the provenance a reader checks that against.

Source code in src/fluxopt/math/results.py
def objective_weights(data: ModelData, objective: str | dict[str, float]) -> dict[str, float]:
    """The weights the objective is actually minimised with.

    Not simply what the caller passed: the built-in penalty effect is added at
    1.0 unless the caller named it, which is what makes a soft constraint cost
    something. Both lanes have to agree here or their objectives are different
    numbers for the same request — and ``Result.objective_weights`` is the
    provenance a reader checks that against.
    """
    from fluxopt.elements import PENALTY_EFFECT_ID

    weights = {objective: 1.0} if isinstance(objective, str) else {k: float(v) for k, v in objective.items()}
    effect_ids = set(data.effects.ids)
    if PENALTY_EFFECT_ID not in weights and PENALTY_EFFECT_ID in effect_ids:
        weights[PENALTY_EFFECT_ID] = 1.0
    return weights

to_result

to_result(
    result: Result,
    data: ModelData,
    weights: dict[str, float],
    model: Spec | None = None,
) -> Result

Build a :class:~fluxopt.results.Result from a solved lpspec model.

Parameters:

Name Type Description Default

result

Result

The lpspec result — primal frames and named expressions.

required

data

ModelData

The model data that was bound. Carries the declaration order, and the tables Result reads alongside the solution.

required

weights

dict[str, float]

Effect ids mapped to their objective weight, for provenance.

required

model

Spec | None

The program that was solved, whose expressions: are read back. Defaults to the shipped one.

None

Returns:

Type Description
Result

The same object the linopy lane returns.

Raises:

Type Description
NoSolutionError

If the solve did not produce a primal solution — there is no result to read, and an empty one would read as zeros. lpspec's own, so a caller sweeping scenarios catches one class for every lane and records the outcome.

Source code in src/fluxopt/math/results.py
def to_result(result: Solved, data: ModelData, weights: dict[str, float], model: Model | None = None) -> Result:
    """Build a :class:`~fluxopt.results.Result` from a solved lpspec model.

    Args:
        result: The lpspec result — primal frames and named expressions.
        data: The model data that was bound. Carries the declaration order,
            and the tables ``Result`` reads alongside the solution.
        weights: Effect ids mapped to their objective weight, for provenance.
        model: The program that was solved, whose ``expressions:`` are read
            back. Defaults to the shipped one.

    Returns:
        The same object the linopy lane returns.

    Raises:
        NoSolutionError: If the solve did not produce a primal solution —
            there is no result to read, and an empty one would read as zeros.
            lpspec's own, so a caller sweeping scenarios catches one class for
            every lane and records the outcome.
    """
    if model is None:
        from fluxopt.math.sources import program

        model = program(data.dims.time_dtype)
    if not result.has_primal:
        raise NoSolutionError(f'no primal solution to read: the solver terminated {result.termination_condition!r}')

    solution: dict[str, xr.DataArray] = {}
    for program_name, var_name in _DIRECT.items():
        if (arr := _read(result, program_name, data)) is not None:
            solution[var_name] = arr
    for program_name, (flow_var, component_var) in _STATUS.items():
        if (arr := _read(result, program_name, data)) is None:
            continue
        on_flows, on_components = _split_status(arr, data)
        if on_flows is not None:
            solution[flow_var] = on_flows
        if on_components is not None:
            solution[component_var] = on_components

    # The lump half of the ledger is a named expression rather than a variable:
    # nothing in the model decides it on its own, so it is read back through
    # the same compiler that built the accounting row.
    expressions = _expressions(result, model, data)
    solution[Var.EFFECT_LUMP] = expressions['effect_lump']

    dataset = xr.Dataset(
        solution,
        attrs={'objective': float(result.objective), 'objective_weights': json.dumps(weights)},
    )
    return Result(solution=dataset, data=data, expressions=expressions)