Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/web/src/components/panels/directions/EvPlanCard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,31 @@ describe("EvPlanCard", () => {
screen.getByText("directions.ev.availability");
});

it("shows when availability was last reported", () => {
render(<EvPlanCard result={baseResult} />, { wrapper: createQueryWrapper() });
screen.getByText("directions.ev.availabilityAsOf");
expect(screen.queryByText("directions.ev.availability")).toBeNull();
});

it("labels a price costed on a member or roaming tariff", () => {
const [stop] = baseResult.stops;
const { rerender } = render(<EvPlanCard result={baseResult} />, {
wrapper: createQueryWrapper(),
});
expect(screen.queryByText(/directions\.ev\.contractTariff/)).toBeNull();

rerender(<EvPlanCard result={{ ...baseResult, stops: [{ ...stop, contractTariff: true }] }} />);
screen.getByText(/directions\.ev\.contractTariff/);
});

it("notes when the plan fell back to straight-line distances", () => {
render(
<EvPlanCard result={{ ...baseResult, warnings: [{ kind: "straight-line-distances" }] }} />,
{ wrapper: createQueryWrapper() },
);
screen.getByText("directions.ev.straightLineDistances");
});

it("says there is no charger data instead of calling the route unreachable", () => {
render(
<EvPlanCard
Expand Down
36 changes: 31 additions & 5 deletions apps/web/src/components/panels/directions/EvPlanCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ export function EvPlanCard({
const hasPartialChargers = result.warnings.some((w) => w.kind === "partial-charger-data");
const hasNoAllowedNetwork = result.warnings.some((w) => w.kind === "no-allowed-network");
const hasTightMargin = result.warnings.some((w) => w.kind === "tight-margin");
const hasStraightLineDistances = result.warnings.some(
(w) => w.kind === "straight-line-distances",
);

const totalSeconds = result.totals.driveSeconds + result.totals.chargeSeconds;
const estimatedCost = result.totals.estimatedCost;
Expand Down Expand Up @@ -166,6 +169,12 @@ export function EvPlanCard({
</Alert>
)}

{hasStraightLineDistances && (
<Alert severity="info" sx={{ mx: 2, my: 1.5 }}>
{t("straightLineDistances")}
</Alert>
)}

{hasNoAllowedNetwork && (
<Alert
severity="warning"
Expand Down Expand Up @@ -227,6 +236,17 @@ function EvPlanStopRow({
unit: stop.tariffPrice.unit,
})
: null;
// A member or roaming rate is what holders of that contract pay, not the price at the charger.
const contractFmt = tariffFmt && stop.contractTariff ? t("contractTariff") : null;
const availabilityTime = stop.availability?.updatedAt
? new Date(stop.availability.updatedAt)
: null;
const availabilityAsOf =
availabilityTime && !Number.isNaN(availabilityTime.getTime())
? new Intl.DateTimeFormat(locale, { hour: "2-digit", minute: "2-digit" }).format(
availabilityTime,
)
: null;
const attributions =
stop.fieldContributors === undefined
? snapshotContributors(stop.contributors ?? stop.attributions)
Expand Down Expand Up @@ -305,10 +325,16 @@ function EvPlanStopRow({
</Typography>
{stop.availability && (
<Typography variant="caption" sx={{ color: "text.secondary" }}>
{t("availability", {
available: stop.availability.available,
total: stop.availability.total,
})}
{availabilityAsOf
? t("availabilityAsOf", {
available: stop.availability.available,
total: stop.availability.total,
time: availabilityAsOf,
})
: t("availability", {
available: stop.availability.available,
total: stop.availability.total,
})}
</Typography>
)}
</Box>
Expand All @@ -317,7 +343,7 @@ function EvPlanStopRow({
variant="caption"
sx={{ color: "text.secondary", display: "block", mt: 0.25 }}
>
{[costFmt, tariffFmt].filter(Boolean).join(" · ")}
{[costFmt, tariffFmt, contractFmt].filter(Boolean).join(" · ")}
</Typography>
)}
</ButtonBase>
Expand Down
47 changes: 47 additions & 0 deletions integrations/routing/ev-plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,53 @@ describe("runEvPlan", () => {
});
// still produces a plan (or a clean warning) rather than throwing
expect(result.warnings.length + result.stops.length).toBeGreaterThan(0);
expect(result.warnings).toContainEqual({ kind: "straight-line-distances" });
});

it("says nothing of straight-line distances when the road matrix answered", async () => {
const ctx = fakeCtx();
const getProviders = (): ResolvedRoutingProvider[] => [
{ integrationId: "valhalla", provider: ctx._valhalla },
];
const result = await runEvPlan(ctx as unknown as IntegrationContext, getProviders, {
waypoints: [
[0, 50],
[2.7, 50],
],
vehicleId: "tesla:model_3:2024:model_3_long_range",
socStartPct: 40,
avoidClosures: false,
});
expect(result.stops.length).toBeGreaterThanOrEqual(1);
expect(result.warnings.some((w) => w.kind === "straight-line-distances")).toBe(false);
});

it("marks a stop costed on a member or roaming tariff, and not one costed ad hoc", async () => {
const plan = async (tariff: EnergyTariff) => {
const ctx = fakeCtx({
getIntegrationsByDomain: chargingDomain(
sitesProvider([chargingSite({ tariffs: [tariff] })]),
),
});
const getProviders = (): ResolvedRoutingProvider[] => [
{ integrationId: "valhalla", provider: ctx._valhalla },
];
return runEvPlan(ctx as unknown as IntegrationContext, getProviders, {
waypoints: [
[0, 50],
[2.7, 50],
],
vehicleId: "tesla:model_3:2024:model_3_long_range",
socStartPct: 40,
avoidClosures: false,
});
};
const member = await plan({ ...energyTariff("m", 0.29), type: "profile_cheap" });
expect(member.stops[0].contractTariff).toBe(true);
const adHoc = await plan({ ...energyTariff("a", 0.59), type: "ad_hoc" });
expect(adHoc.stops[0]).not.toHaveProperty("contractTariff");
const untyped = await plan(energyTariff("u", 0.59));
expect(untyped.stops[0]).not.toHaveProperty("contractTariff");
});

it("omits trip cost when a planned stop has no tariff, even with a home price", async () => {
Expand Down
11 changes: 11 additions & 0 deletions integrations/routing/ev-plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ export async function runEvPlan(

const sites = chargingSites(ctx);
let partialChargerData = false;
let straightLineDistances = false;
const nowMs = Date.now();

const socStartKwh = (args.socStartPct / 100) * vehicle.batteryKwh;
Expand Down Expand Up @@ -329,6 +330,7 @@ export async function runEvPlan(
ctx.log.warn("[ev] matrix failed; using great-circle", e as Error);
}
}
straightLineDistances = true;
return greatCircleMatrix(sources, targets);
},
},
Expand Down Expand Up @@ -435,6 +437,9 @@ export async function runEvPlan(
addedKwh: Math.round(s.addedKwh * 10) / 10,
availability: availabilityOf(s.site),
tariffPrice: s.estimatedCost?.price,
...(isContractTariff(s.estimatedCost?.tariffType)
? { contractTariff: true as const }
: {}),
estimatedCost: s.estimatedCost && {
amount: roundToMinorUnit(s.estimatedCost.amount, s.estimatedCost.currency),
currency: s.estimatedCost.currency,
Expand Down Expand Up @@ -482,6 +487,7 @@ export async function runEvPlan(
...plan.warnings,
...revalidationWarnings,
...(partialChargerData ? [{ kind: "partial-charger-data" } as const] : []),
...(straightLineDistances ? [{ kind: "straight-line-distances" } as const] : []),
],
};
};
Expand All @@ -490,6 +496,11 @@ export async function runEvPlan(
: buildPlan();
}

/** A stated tariff type other than ad hoc: a member, roaming or profile rate. */
function isContractTariff(type: string | undefined): boolean {
return type !== undefined && type !== "ad_hoc";
}

/** Deterministic cache key: rounded waypoints + vehicle + bucketed inputs. */
function evPlanCacheKey(
args: EvPlanArgs,
Expand Down
11 changes: 10 additions & 1 deletion packages/core/src/types/routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,12 @@ export interface EvChargeStop extends ContributingRecord {
availability?: { available: number; total: number; updatedAt?: string };
/** The main price of the tariff the session was costed on; the client formats it in its locale. */
tariffPrice?: TariffPrice;
/**
* True when that tariff is of a stated type other than ad hoc (a member,
* roaming or profile rate): a price for holders of that contract, not what
* anyone can pay at the charger. Absent when the type is ad hoc or unstated.
*/
contractTariff?: true;
/** Modelled cost of this charging session. */
estimatedCost?: { amount: number; currency: string };
attributions: Attribution[];
Expand All @@ -498,14 +504,17 @@ export interface EvChargeStop extends ContributingRecord {
* and no charging site was found (or none is configured);
* `charger-sources-unavailable` that a stop was needed and no charger source
* answered for it; `partial-charger-data` that a charger source did not answer
* in full, so a better stop may exist.
* in full, so a better stop may exist; `straight-line-distances` that the road
* distances between candidate stops were not available, so the plan used
* straight-line estimates for them.
*/
export type EvPlanWarning =
| { kind: "unreachable"; afterStopIndex: number }
| { kind: "tight-margin"; legIndex: number }
| { kind: "no-charger-data" }
| { kind: "charger-sources-unavailable" }
| { kind: "partial-charger-data" }
| { kind: "straight-line-distances" }
| { kind: "no-allowed-network"; afterStopIndex: number };

/**
Expand Down
20 changes: 20 additions & 0 deletions packages/ev-charge-planner/src/plan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -889,6 +889,26 @@ describe("estimateSessionCost", () => {
);
});

it("reports the costed tariff's stated type, and none when it states none", () => {
const weekendAdHoc: ChargingSite = {
...site("s", 1),
tariffs: [
tariff("cheap", [energy(0.2)], { type: "profile_cheap" }),
tariff("adhoc", [energy(0.5, { days: ["SA", "SU"] })], { type: "ad_hoc" }),
],
};
expect(estimateSessionCost(weekendAdHoc, pickOn(weekendAdHoc), facts())?.tariffType).toBe(
"profile_cheap",
);
const adHoc: ChargingSite = {
...site("s", 1),
tariffs: [tariff("adhoc", [energy(0.5)], { type: "ad_hoc" })],
};
expect(estimateSessionCost(adHoc, pickOn(adHoc), facts())?.tariffType).toBe("ad_hoc");
const untyped: ChargingSite = { ...site("s", 1), tariffs: [tariff("t", [energy(0.5)])] };
expect(estimateSessionCost(untyped, pickOn(untyped), facts())).not.toHaveProperty("tariffType");
});

it("the card's tariff summary is the costed tariff, not the first", () => {
const base = site("s", 1);
const s: ChargingSite = {
Expand Down
1 change: 1 addition & 0 deletions packages/ev-charge-planner/src/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,7 @@ function costOn(
amount,
currency: tariff.currency,
tariffId: tariff.id,
...(tariff.type !== undefined ? { tariffType: tariff.type } : {}),
price: {
// Up to four decimals, as prices are shown; VAT grossing adds float noise.
amount: Math.round(main.price * gross(main) * 10_000) / 10_000,
Expand Down
2 changes: 2 additions & 0 deletions packages/ev-charge-planner/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export interface SessionCost {
amount: number;
currency: string;
tariffId: string;
/** The costed tariff's type as published (e.g. `ad_hoc`, `profile_cheap`); absent when unstated. */
tariffType?: string;
/** The costed tariff's main price, e.g. 0.39 EUR per kWh; the client formats it in its locale. */
price: TariffPrice;
}
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -1120,6 +1120,9 @@
"tripCostBreakdown": "{homeKwh} kWh zu Hause · {publicKwh} kWh öffentlich",
"stopArriveDepart": "{arrive} % → {depart} %",
"availability": "{available} von {total} verfügbar",
"straightLineDistances": "Straßenentfernungen zwischen den Ladestopps waren nicht verfügbar, daher nutzt dieser Plan Luftlinien-Schätzungen. Ladezeiten und Ankunftsladestand können abweichen.",
"contractTariff": "Mitglieds- oder Roamingpreis",
"availabilityAsOf": "{available} von {total} verfügbar, Stand {time}",
"onYourNetwork": "In deinem Netzwerk",
"stopsHeading": "Ladestopps",
"unreachable": "Keine ladbare Route gefunden — versuche ein anderes Fahrzeug oder einen anderen Startladestand.",
Expand Down
3 changes: 3 additions & 0 deletions packages/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -1120,6 +1120,9 @@
"tripCostBreakdown": "{homeKwh} kWh at home · {publicKwh} kWh public",
"stopArriveDepart": "{arrive}% → {depart}%",
"availability": "{available} of {total} available",
"straightLineDistances": "Road distances between charging stops were unavailable, so this plan uses straight-line estimates. Charge times and arrival charge may differ.",
"contractTariff": "member or roaming rate",
"availabilityAsOf": "{available} of {total} available as of {time}",
"onYourNetwork": "On your network",
"stopsHeading": "Charging stops",
"unreachable": "No chargeable route found — try a different vehicle or start charge level.",
Expand Down
Loading