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 work is relabelling. The program indexes time, period and build_period by ordinal, because the engine joins on them and a timestamp is a poor join key; :class:~fluxopt.results.Result is read by humans and indexes them by the labels the element layer used. Everything else is a rename: one program variable to one Var name.

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 labels the program indexes by ordinal, 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 labels the program
            indexes by ordinal, 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()
    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)