using Gleanvolt.Core.Enums;
using Gleanvolt.Core.Models;
namespace Gleanvolt.Core.Strategies;
///
/// Turns what somebody asked for — an amount of energy or a state of charge, by a time, optimised one
/// of two ways — into the the controller works to, or into the
/// reason it cannot be one.
///
/// One place, because there are now several doors. The web UI's form and the HTTP API
/// both compose requests, and the two must not be able to disagree about what is valid, where the
/// SOC → kWh conversion happens, or how the just-in-time tail is split off. Everything here was the
/// targeted tab's own TryCompose before the API existed; it is pure, so it belongs in
/// Gleanvolt.Core with the planner it feeds.
///
/// Both conversions happen once, here, at the moment the request is made, and nothing
/// downstream re-derives them from a later reading — the rule
/// explains at length: a parked car's cloud SOC
/// arrives when it feels like it, and a promise already half delivered must not move at 02:00 because
/// the car finally phoned home.
///
/// That one-shot rule is exactly why is checked before the
/// conversion rather than after (#179): if the amount is fixed here for good, the reading it is fixed
/// from has to be one worth fixing on.
///
public static class TargetedChargeRequestFactory
{
///
/// The composed request, or the message to put in front of whoever asked. Exactly one of the two
/// is non-null.
///
public sealed record Result(TargetedChargeRequest? Request, string? Error)
{
internal static Result Ok(TargetedChargeRequest request) => new(request, null);
internal static Result Rejected(string error) => new(null, error);
}
///
/// Composes a request from an energy figure or a target state of charge — supply exactly one.
///
/// Rejections are the interesting part and are all stated in the terms they were asked in:
/// a car already at or above the target, a departure in the past, a departure past the horizon,
/// a SOC target on an installation that cannot convert one, and a SOC target measured from a
/// reading too old to convert from (#179).
///
/// When the energy has to be in the car.
/// The energy asked for, at the charger. Null when asking in state of charge.
/// The state of charge asked for. Null when asking in energy.
/// What to optimise while delivering it.
///
/// Where a just-in-time hold parks the car, or null for .
/// Ignored under .
///
///
/// What the car last reported and whether that is still worth converting from (#179). A reading
/// older than Vehicle:MaxAge is refused rather than spent, since nothing downstream
/// re-derives the amount once it is set.
///
/// The installation's horizon and pack figures.
/// The instant the request is being made — what delivery is metered from.
public static Result Create(
DateTimeOffset departBy,
double? energyWh,
double? targetSocPercent,
TargetedChargePriority priority,
double? restSocPercent,
VehicleSocBasis vehicleSoc,
TargetedChargeRequestLimits limits,
DateTimeOffset now)
{
ArgumentNullException.ThrowIfNull(vehicleSoc);
double askedWh;
double? soc = null;
double? socNow = null;
if (targetSocPercent is { } target)
{
if (energyWh is not null)
{
return Result.Rejected("Ask for energy or for a state of charge, not both.");
}
if (!limits.CanTargetSoc)
{
return Result.Rejected(
"A target state of charge needs the car's usable capacity, which is not configured "
+ "(Vehicle:BatteryCapacityKWh). Ask in kilowatt-hours instead.");
}
// Asked before the conversion, and in its own words: a reading too old to trust and a
// reading that never arrived both leave RequiredWh with nothing to work from, and the two
// want opposite things from an owner (#179). Refusing here is the whole guard -- the amount
// is fixed at this moment and nothing downstream revisits it.
if (vehicleSoc.StaleRefusal is { } tooOld)
{
return Result.Rejected(tooOld);
}
socNow = vehicleSoc.ConvertibleSocPercent;
var requiredWh = VehicleTargetEnergy.RequiredWh(
socNow, target, limits.BatteryCapacityWh, limits.ChargeEfficiency);
if (requiredWh is null)
{
return Result.Rejected(
"The car has not reported a state of charge, so there is nothing to measure the gap "
+ "from. Ask in kilowatt-hours instead.");
}
if (requiredWh.Value <= 0)
{
return Result.Rejected(
$"The car is already at {socNow:F0}%, at or above the {target:F0}% asked for.");
}
askedWh = requiredWh.Value;
soc = target;
}
else
{
if (energyWh is not { } wh || wh <= 0)
{
return Result.Rejected("Enter how much energy the car needs.");
}
askedWh = wh;
}
if (departBy <= now)
{
return Result.Rejected("The departure time is in the past.");
}
if (departBy - now > limits.MaxHorizon)
{
return Result.Rejected(
$"A departure more than {limits.MaxHorizon.TotalHours:F0} hours away is further than "
+ "the forecast — or a request that does not survive a restart — can honestly promise.");
}
var request = new TargetedChargeRequest(askedWh, departBy, now, soc, socNow) with { Priority = priority };
// The split, made once and here — the same place and the same moment as the SOC → kWh
// conversion above, so a reading that lands later cannot move a promise already part delivered.
// The planner is handed watt-hours and never sees a percentage again.
if (priority == TargetedChargePriority.JustInTime && limits.CanTargetSoc)
{
var rest = restSocPercent ?? limits.DefaultRestSocPercent;
// Deliberately the raw reading rather than the guarded one, and only on this path: an
// energy request's amount is what the owner typed and is never derived from the car, so a
// stale percentage can only mis-time the held tail -- it cannot change how much is
// delivered. Refusing here would gate charging on the feed, which #179 rules out; a plan
// that holds the wrong stretch back still delivers the energy asked for.
var endSoc = soc ?? VehicleTargetEnergy.ResultingSocPercent(
vehicleSoc.SocPercent, askedWh, limits.BatteryCapacityWh, limits.ChargeEfficiency);
var tailWh = endSoc is { } end
? VehicleTargetEnergy.TailAboveRestWh(
vehicleSoc.SocPercent, end, rest, limits.BatteryCapacityWh, limits.ChargeEfficiency)
: null;
request = request with
{
// Never more than the request itself: a rest point below where the car already sits
// would otherwise hold back energy the request never contained.
TailEnergyWh = Math.Clamp(tailWh ?? 0, 0, askedWh),
RestSocPercent = rest,
};
}
return Result.Ok(request);
}
}