diff --git a/CHANGELOG.md b/CHANGELOG.md index 7322327..8317de0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,21 @@ is never blocked out from under the requirement (so `via: ['northeast']` needs no `allowArctic`); naming a passage in both `via` and `restrictions` throws `NoRouteError`. (#8) +- Emissions & ECA/SECA reporting (opt-in, rough estimates). Pass + `emissions: true` to fill new `properties`: + - `ecaKm` / `ecaFraction` — how much of the route lies inside ECA/SECA + emission-control areas (Baltic, North Sea, Mediterranean, North American, US + Caribbean). The zones ship behind a new `searoute-ts/eca` subpath export + (bounding-box approximations of the IMO MARPOL Annex VI areas); importing it + registers them, so the core stays lean. Also exports `ECA_ZONES`, + `registerEcaZones`, `getEcaZones`, `hasEcaZones`, `ecaDistanceKm`, `EcaZone`, + `EcaBbox`. + - `co2eTonnes` — a rough CO₂e estimate (`distance × vessel-class factor`), when + a `vesselClass` or `co2eFactorKgPerKm` is given. Factors derive transparently + from representative fuel burn and the IMO HFO CO₂ conversion; `glecInflation` + applies the GLEC ~15 % distance uplift. New exports `VESSEL_CLASSES`, + `co2eFactorKgPerKm`, `CO2_PER_TONNE_FUEL`, `VesselClass`, `VesselClassSpec`. + Documented clearly as estimates, not certified figures. (#12) ## 2.2.0 — 2026-07-03 diff --git a/DOCS.md b/DOCS.md index 7ea481c..336726b 100644 --- a/DOCS.md +++ b/DOCS.md @@ -133,9 +133,19 @@ Once the LineString is built, properties are computed: - `originSnapKm`, `destinationSnapKm` — how far the inputs were from the snapped vertex. - `durationHours` — `routeLengthKm / (speedKnots × 1.852)` when `speedKnots > 0`. - `passages` — when `returnPassages: true`, lists the named passages whose bboxes contain at least one route coordinate. +- `ecaKm` / `ecaFraction` / `co2eTonnes` — when `emissions: true` (see below). The `length` is always measured on the **in-water** portion only. If you set `appendOriginDestination: true`, the LineString has the raw origin and destination prepended/appended, but `length` stays in-water — so it's stable across that toggle. +### Emissions & ECA/SECA (opt-in) + +Setting `emissions: true` fills in two independent, deliberately rough estimates: + +- **`ecaKm` / `ecaFraction`** — kilometres of the in-water route inside ECA/SECA emission-control zones, and that as a fraction of `length`. This reuses the same idea as bbox passage detection: each route segment is subdivided into ~5 km steps and a step counts as in-zone when its midpoint falls inside any zone bbox. The zones themselves ship behind the `searoute-ts/eca` subpath export (so the core stays lean, per #10); importing it calls `registerEcaZones` with the defaults. Those defaults are **bounding-box approximations** of the IMO MARPOL Annex VI areas (Baltic, North Sea + Channel, Mediterranean, North American Pacific/Atlantic/Gulf, US Caribbean) — fine for a rough figure, not authoritative boundaries. The North American and US Caribbean ECAs really follow a 200 nm offset from the baseline; here they are coarse coastal envelopes. Replace all of them with full polygons via `registerEcaZones(zones)`. +- **`co2eTonnes`** — a rough `distanceKm × factor` estimate, only when a `vesselClass` (see `VESSEL_CLASSES`) or an explicit `co2eFactorKgPerKm` is given. Per-class factors are derived transparently as `fuelTonnesPerDay × 3.114 (IMO HFO Cf) × 1000 / (24 × serviceSpeedKnots × 1.852)`, i.e. kg CO₂e per km. It is an order-of-magnitude estimate, **not** a certified figure. `glecInflation` (e.g. `0.15`) inflates the distance used for CO₂e to allow for real-world deviation from the shortest path, per the GLEC Framework; it affects `co2eTonnes` only. + +Both are computed for `seaRoute` and `seaRouteMulti`. `ecaKm` unwraps the route's coordinates before subdividing, so segments that cross the ±180° antimeridian interpolate the short way round (midpoints are wrapped back into ±180° for the zone test) — a trans-Pacific route never phantom-intersects zones on the other side of the globe. + --- ## 6. Multi-leg routes (`seaRouteMulti`) diff --git a/README.md b/README.md index 35ebaec..e59b5a0 100644 --- a/README.md +++ b/README.md @@ -261,6 +261,9 @@ GDAL conversion), host the resulting JSON, and load it with destinationSnapKm: number, durationHours?: number, // if `speedKnots` set passages?: ('suez' | 'panama' | ...)[], // if `returnPassages: true` + ecaKm?: number, // if `emissions` + `searoute-ts/eca` imported + ecaFraction?: number, // ecaKm / length (0–1) + co2eTonnes?: number, // if `emissions` + `vesselClass`/factor } } ``` @@ -280,6 +283,10 @@ seaRoute(origin, destination, { maxSnapDistanceKm: 50, // SnapFailedError if exceeded network: customMarnet, // BYO FeatureCollection antimeridian: 'split', // 'unwrap' | 'split' dateline handling + emissions: true, // → properties.ecaKm / co2eTonnes + vesselClass: 'panamax', // CO₂e estimate class + co2eFactorKgPerKm: 225, // override the class factor + glecInflation: 0.15, // +15% distance for CO₂e (GLEC) }); ``` @@ -321,6 +328,36 @@ requirement (`via: ['northeast']` reaches the Northeast Passage without also needing `allowArctic`). Naming the same passage in both `via` and `restrictions` is a contradiction and throws `NoRouteError`. +### Emissions & ECA/SECA reporting + +Opt in with `emissions: true` for two rough estimates on `properties`: + +```ts +import 'searoute-ts/eca'; // load the ECA/SECA zones (enables ecaKm) +import { seaRoute } from 'searoute-ts'; + +const r = seaRoute('CNSHA', 'NLRTM', { + emissions: true, + vesselClass: 'panamax', // → co2eTonnes +}); +r.properties.ecaKm; // km of the route inside emission-control zones +r.properties.ecaFraction; // that as a fraction of route length (0–1) +r.properties.co2eTonnes; // rough CO₂e estimate for the voyage +``` + +- **`ecaKm`** — how much of the route lies inside ECA/SECA emission-control + areas (Baltic, North Sea, Mediterranean, North American and US Caribbean), + which drives fuel-type/cost. The zones ship behind the `searoute-ts/eca` + subpath export (to keep the core lean); importing it registers them. They are + **bounding-box approximations** of the IMO MARPOL Annex VI areas — good for + estimates, not compliance. Swap in higher-fidelity polygons with + `registerEcaZones`. +- **`co2eTonnes`** — a deliberately simple `distance × vessel-class factor` + estimate, **not a certified figure**. Factors are derived transparently from a + representative fuel burn and the IMO HFO CO₂ conversion (see `VESSEL_CLASSES`); + override with `co2eFactorKgPerKm`. GLEC recommends inflating shortest-path + distance by ~15 % for real-world deviations — pass `glecInflation: 0.15`. + ## Restrictable passages The first twelve are **natively labelled** in the Eurostat marnet (exact match diff --git a/package.json b/package.json index 2fc2097..a16aac2 100644 --- a/package.json +++ b/package.json @@ -26,10 +26,16 @@ "import": "./dist/esm/marnet/marnet-50km.js", "require": "./dist/cjs/marnet/marnet-50km.js" }, + "./eca": { + "types": "./dist/types/eca/index.d.ts", + "import": "./dist/esm/eca/index.js", + "require": "./dist/cjs/eca/index.js" + }, "./package.json": "./package.json" }, "sideEffects": [ - "**/ports/index.js" + "**/ports/index.js", + "**/eca/index.js" ], "repository": { "type": "git", diff --git a/src/eca/data.ts b/src/eca/data.ts new file mode 100644 index 0000000..f958c9a --- /dev/null +++ b/src/eca/data.ts @@ -0,0 +1,59 @@ +import type { EcaZone } from '../lib/eca.js'; + +/** + * Default emission-control-area (ECA/SECA) zones, approximated by bounding-box + * envelopes of each area's sea extent. + * + * These are ROUGH approximations of the IMO MARPOL Annex VI designated areas — + * good enough for the estimate `properties.ecaKm` reports, but not authoritative + * boundaries. The North American and US Caribbean ECAs in particular follow a + * 200 nm offset from the baseline; here they are coarse coastal envelopes. Swap + * in higher-fidelity polygons via `registerEcaZones` when precision matters. + * + * Coverage (SOx/SECA and combined ECAs): + * - Baltic Sea SECA + * - North Sea SECA (incl. the English Channel) + * - Mediterranean Sea SECA (in force 1 May 2025) + * - North American ECA (Pacific, Atlantic, Gulf of Mexico coasts) + * - US Caribbean ECA (Puerto Rico & the US Virgin Islands) + * + * Sources: IMO MARPOL Annex VI, Regulations 13 & 14 and Appendix VII (ECA + * definitions); Mediterranean SECA adopted at MEPC 79 (in force 2025-05-01). + */ +const DEFAULT_ECA_ZONES: EcaZone[] = [ + { + name: 'Baltic Sea SECA', + bboxes: [ + [12.0, 53.6, 30.5, 66.0], + [10.5, 55.3, 13.0, 58.2], + ], + }, + { + name: 'North Sea SECA', + bboxes: [ + [-2.0, 51.0, 9.0, 62.0], + [-5.5, 48.5, 2.0, 51.2], + ], + }, + { + name: 'Mediterranean Sea SECA', + bboxes: [ + [-6.0, 30.0, 26.0, 46.0], + [22.0, 30.0, 37.0, 41.0], + ], + }, + { + name: 'North American ECA', + bboxes: [ + [-130.0, 30.0, -116.0, 49.5], + [-77.0, 25.0, -60.0, 47.0], + [-98.0, 24.0, -80.0, 31.0], + ], + }, + { + name: 'US Caribbean ECA', + bboxes: [[-68.5, 16.5, -63.5, 20.0]], + }, +]; + +export default DEFAULT_ECA_ZONES; diff --git a/src/eca/index.spec.ts b/src/eca/index.spec.ts new file mode 100644 index 0000000..c5a02cc --- /dev/null +++ b/src/eca/index.spec.ts @@ -0,0 +1,101 @@ +import test from 'ava'; +import type { Feature, Point } from 'geojson'; + +import { ecaDistanceKm, getEcaZones, hasEcaZones, registerEcaZones, seaRoute } from '../index'; +// Importing the subpath registers the default ECA zones as a side effect. +import { ECA_ZONES } from './index'; + +function pt(lon: number, lat: number): Feature { + return { + type: 'Feature', + properties: {}, + geometry: { type: 'Point', coordinates: [lon, lat] }, + }; +} + +const ROTTERDAM = pt(4.4, 51.9); +const LONDON = pt(-0.13, 51.5); +const SINGAPORE = pt(103.8, 1.3); +const MUMBAI = pt(72.9, 19.0); + +test('importing searoute-ts/eca registers the default ECA zones', (t) => { + t.true(hasEcaZones()); + t.is(ECA_ZONES.length, 5); + t.is(getEcaZones().length, 5); +}); + +test('a North Sea route reports most of its length inside an ECA', (t) => { + const r = seaRoute(ROTTERDAM, LONDON, { units: 'kilometers', emissions: true }); + t.true((r.properties.ecaKm ?? 0) > 0, 'should have ECA distance'); + t.true((r.properties.ecaKm ?? 0) <= r.properties.length + 1e-6, 'ecaKm cannot exceed length'); + t.true( + (r.properties.ecaFraction ?? 0) > 0.5, + `southern North Sea should be mostly in-zone, got ${r.properties.ecaFraction}`, + ); +}); + +test('an open-ocean route outside any ECA reports ~0 ECA distance', (t) => { + const r = seaRoute(SINGAPORE, MUMBAI, { units: 'kilometers', emissions: true }); + t.true( + (r.properties.ecaKm ?? 0) < r.properties.length * 0.05, + `Indian Ocean route should be out of zone, got ecaKm ${r.properties.ecaKm} of ${r.properties.length}`, + ); +}); + +test('ecaKm and co2eTonnes can be reported together', (t) => { + const r = seaRoute(ROTTERDAM, LONDON, { + units: 'kilometers', + emissions: true, + vesselClass: 'feeder', + }); + t.true((r.properties.ecaKm ?? 0) > 0); + t.true((r.properties.co2eTonnes ?? 0) > 0); +}); + +test('ecaDistanceKm measures a line inside the Mediterranean SECA', (t) => { + // ~2° of longitude at 42°N off the French/Spanish Med coast, fully in-zone. + const km = ecaDistanceKm([ + [4.0, 42.0], + [6.0, 42.0], + ]); + t.true(km > 100 && km < 220, `expected ~165 km inside the Med, got ${km}`); +}); + +test('ecaDistanceKm reports 0 for a mid-Pacific segment crossing the antimeridian', (t) => { + // 170°E → 170°W at 45°N: the short way crosses the dateline, thousands of km + // from any ECA. Linear interpolation in wrapped lon/lat would sweep the + // midpoints the long way round through 0° — straight through the + // Mediterranean and North Sea boxes. + const km = ecaDistanceKm([ + [170.0, 45.0], + [-170.0, 45.0], + ]); + t.is(km, 0, `dateline-crossing open-ocean segment must be out of zone, got ${km}`); +}); + +test('a trans-Pacific route crossing the dateline reports 0 ECA distance', (t) => { + // Yokohama → Honolulu: crosses the antimeridian, never nears an ECA + // (Hawaii is outside the North American ECA). + const r = seaRoute(pt(139.65, 35.44), pt(-157.86, 21.3), { + units: 'kilometers', + emissions: true, + }); + t.is( + r.properties.ecaKm ?? 0, + 0, + `mid-Pacific route must have no ECA distance, got ${r.properties.ecaKm}`, + ); +}); + +test.serial('registerEcaZones swaps in custom zones (restored synchronously)', (t) => { + const original = getEcaZones(); + registerEcaZones([{ name: 'test-box', bboxes: [[0, 0, 10, 10]] }]); + const km = ecaDistanceKm([ + [1, 5], + [9, 5], + ]); + t.true(km > 800, `custom zone should capture the ~8° line, got ${km}`); + // Restore before the test returns (no awaits ⇒ atomic w.r.t. other tests). + registerEcaZones([...original]); + t.is(getEcaZones().length, original.length); +}); diff --git a/src/eca/index.ts b/src/eca/index.ts new file mode 100644 index 0000000..04a84d2 --- /dev/null +++ b/src/eca/index.ts @@ -0,0 +1,17 @@ +import { type EcaZone, registerEcaZones } from '../lib/eca.js'; +import DEFAULT_ECA_ZONES from './data.js'; + +export type { EcaZone } from '../lib/eca.js'; +export { registerEcaZones } from '../lib/eca.js'; + +/** + * The bundled default ECA/SECA zones (bounding-box approximations of the IMO + * MARPOL Annex VI designated areas). See `src/eca/data.ts` for coverage, + * caveats and sources. + */ +export const ECA_ZONES: readonly EcaZone[] = DEFAULT_ECA_ZONES; + +// Enable `seaRoute(o, d, { emissions: true }).properties.ecaKm` in the core: +// importing this module registers the default zones. This is the module's +// intended side effect. +registerEcaZones(DEFAULT_ECA_ZONES); diff --git a/src/index.spec.ts b/src/index.spec.ts index 7b11732..cc55be4 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -4,6 +4,7 @@ import type { Feature, Point } from 'geojson'; import { CANAL_MAX_DRAFT_M, clearFinderCache, + co2eFactorKgPerKm, DEFAULT_MARNET, loadNetwork, NoRouteError, @@ -655,6 +656,69 @@ test('a UN/LOCODE string throws UnknownPortError when no dataset is registered', t.regex(err!.message, /searoute-ts\/ports/); }); +// ── Emissions: CO₂e (ECA distance needs the searoute-ts/eca subpath) ───────── + +test('emissions without a vessel class or zones adds no emission properties', (t) => { + const r = seaRoute(NYC, LONDON, { units: 'kilometers', emissions: true }); + // The core does not bundle ECA zones; without importing 'searoute-ts/eca', + // ecaKm is not computed. No vesselClass/factor → no CO₂e either. + t.is(r.properties.ecaKm, undefined); + t.is(r.properties.co2eTonnes, undefined); +}); + +test('emissions + vesselClass gives a rough CO₂e estimate that scales with distance', (t) => { + const short = seaRoute(NYC, LONDON, { + units: 'kilometers', + emissions: true, + vesselClass: 'panamax', + }); + const long = seaRoute(SHANGHAI, ROTTERDAM, { + units: 'kilometers', + emissions: true, + vesselClass: 'panamax', + }); + t.true((short.properties.co2eTonnes ?? 0) > 0); + t.true((long.properties.co2eTonnes ?? 0) > (short.properties.co2eTonnes ?? 0)); + // distance (km) × factor (kg/km) / 1000 + const expected = (short.properties.length * co2eFactorKgPerKm('panamax')) / 1000; + t.true(Math.abs((short.properties.co2eTonnes ?? 0) - expected) < 1e-6); +}); + +test('co2eFactorKgPerKm overrides the vesselClass default', (t) => { + const r = seaRoute(NYC, LONDON, { + units: 'kilometers', + emissions: true, + vesselClass: 'panamax', + co2eFactorKgPerKm: 100, + }); + // 100 kg/km → tonnes = length(km) / 10 + t.true(Math.abs((r.properties.co2eTonnes ?? 0) - r.properties.length / 10) < 1e-6); +}); + +test('glecInflation scales the CO₂e estimate', (t) => { + const base = seaRoute(NYC, LONDON, { + units: 'kilometers', + emissions: true, + co2eFactorKgPerKm: 100, + }); + const inflated = seaRoute(NYC, LONDON, { + units: 'kilometers', + emissions: true, + co2eFactorKgPerKm: 100, + glecInflation: 0.15, + }); + t.true( + Math.abs((inflated.properties.co2eTonnes ?? 0) - (base.properties.co2eTonnes ?? 0) * 1.15) < + 1e-6, + ); +}); + +test('without the emissions flag, no emission properties are set', (t) => { + const r = seaRoute(NYC, LONDON, { units: 'kilometers', vesselClass: 'panamax' }); + t.is(r.properties.co2eTonnes, undefined); + t.is(r.properties.ecaKm, undefined); +}); + // ── Custom network ────────────────────────────────────────────────────────── test('custom network option is honoured', (t) => { diff --git a/src/index.ts b/src/index.ts index e8da634..83c733f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,8 @@ import type { Feature, LineString, MultiLineString, Point, Position } from 'geoj import { type Antimeridian, splitAtAntimeridian, unwrapCoords } from './lib/antimeridian.js'; import { passagesBlockedByDraft } from './lib/drafts.js'; +import { ecaDistanceKm, hasEcaZones } from './lib/eca.js'; +import { co2eFactorKgPerKm, type VesselClass } from './lib/emissions.js'; import { buildFinder, DEFAULT_MARNET, type MarnetNetwork } from './lib/finder.js'; import { bboxOf, greatCircleKm } from './lib/metrics.js'; import { resolvePortCode } from './lib/ports.js'; @@ -29,6 +31,21 @@ export type { MarnetNetwork } from './lib/finder.js'; export { DEFAULT_MARNET, clearFinderCache } from './lib/finder.js'; export { SnapFailedError } from './lib/snap.js'; export { CANAL_MAX_DRAFT_M } from './lib/drafts.js'; +export { + registerEcaZones, + getEcaZones, + hasEcaZones, + ecaDistanceKm, + type EcaZone, + type EcaBbox, +} from './lib/eca.js'; +export { + co2eFactorKgPerKm, + VESSEL_CLASSES, + CO2_PER_TONNE_FUEL, + type VesselClass, + type VesselClassSpec, +} from './lib/emissions.js'; export { UnknownPortError, registerPortResolver, @@ -132,6 +149,33 @@ export type SeaRouteOptions = { * always returns wrapped `LineString`s. */ antimeridian?: Antimeridian; + /** + * Emissions reporting (opt-in, rough estimates — not certified figures). + * When truthy: + * - `properties.ecaKm` / `properties.ecaFraction` report how much of the + * in-water route lies inside ECA/SECA emission-control zones. This needs + * the zones loaded — import `searoute-ts/eca` (or call `registerEcaZones`) + * first; without it these stay `undefined`. + * - if a `vesselClass` or `co2eFactorKgPerKm` is also given, + * `properties.co2eTonnes` reports a rough CO₂e estimate. + */ + emissions?: boolean; + /** + * Vessel class for the CO₂e estimate (see `VESSEL_CLASSES`). Only used when + * `emissions` is set. Ignored if `co2eFactorKgPerKm` is provided. + */ + vesselClass?: VesselClass; + /** + * Override the CO₂e emission factor, in kg CO₂e per km. Takes precedence over + * the `vesselClass` default. Only used when `emissions` is set. + */ + co2eFactorKgPerKm?: number; + /** + * Inflate the distance used for the CO₂e estimate to allow for real-world + * deviations from the shortest path. GLEC recommends ~0.15 (i.e. +15 %). + * Default 0. Affects `co2eTonnes` only — not `ecaKm` or `length`. + */ + glecInflation?: number; }; export type SeaRouteProperties = { @@ -155,6 +199,20 @@ export type SeaRouteProperties = { originSnapKm: number; /** Snap distance from input destination to the network vertex used, in km. */ destinationSnapKm: number; + /** + * Kilometres of the in-water route inside ECA/SECA emission-control zones. + * Present when `emissions` is set and zones are registered (via + * `searoute-ts/eca` or `registerEcaZones`). A rough, bbox-based estimate. + */ + ecaKm?: number; + /** Fraction (0–1) of the route length inside ECA/SECA zones. */ + ecaFraction?: number; + /** + * Rough CO₂e estimate in tonnes. Present when `emissions` is set together + * with a `vesselClass` or `co2eFactorKgPerKm`. An order-of-magnitude + * estimate (distance × factor), not a certified figure. + */ + co2eTonnes?: number; }; export type SeaRouteFeature = Feature; @@ -267,6 +325,8 @@ export function seaRoute( properties.passages = passagesAlong(result.path); } + applyEmissions(inWaterCoords, properties, lenKm, options); + return buildRouteFeature(coords, properties, options.antimeridian); } @@ -340,6 +400,8 @@ export function seaRouteMulti( properties.passages = Array.from(set); } + applyEmissions(coords, properties, totalKm, options); + return buildRouteFeature(coords, properties, options.antimeridian); } @@ -556,6 +618,35 @@ function routeVia( return route; } +/** + * Fill in the opt-in emissions properties (`ecaKm`, `ecaFraction`, + * `co2eTonnes`). No-op unless `options.emissions` is truthy. `ecaKm` needs ECA + * zones registered (import `searoute-ts/eca`); `co2eTonnes` needs a + * `vesselClass` or `co2eFactorKgPerKm`. + */ +function applyEmissions( + inWaterCoords: Position[], + properties: SeaRouteProperties, + lenKm: number, + options: SeaRouteOptions, +): void { + if (!options.emissions) return; + + if (hasEcaZones()) { + const ecaKm = ecaDistanceKm(inWaterCoords); + properties.ecaKm = ecaKm; + properties.ecaFraction = lenKm > 0 ? ecaKm / lenKm : 0; + } + + const factor = + options.co2eFactorKgPerKm ?? + (options.vesselClass ? co2eFactorKgPerKm(options.vesselClass) : undefined); + if (factor !== undefined) { + const inflation = options.glecInflation ?? 0; + properties.co2eTonnes = (lenKm * (1 + inflation) * factor) / 1000; + } +} + /** * Build the output feature from route coordinates, applying the antimeridian * option: `'split'` yields a `MultiLineString` cut at ±180°, `'unwrap'` yields a diff --git a/src/lib/eca.ts b/src/lib/eca.ts new file mode 100644 index 0000000..ba160b2 --- /dev/null +++ b/src/lib/eca.ts @@ -0,0 +1,89 @@ +import type { Position } from 'geojson'; + +import { unwrapCoords } from './antimeridian.js'; +import { greatCircleKm } from './metrics.js'; + +/** Axis-aligned bbox: [minLon, minLat, maxLon, maxLat]. */ +export type EcaBbox = [number, number, number, number]; + +/** + * An emission-control area, approximated by one or more axis-aligned bounding + * boxes. Real ECA/SECA boundaries are complex polygons (the North American ECA + * follows a 200 nm offset from the baseline); these bbox envelopes are a rough + * approximation suitable for the estimate that `properties.ecaKm` reports, and + * can be replaced with authoritative polygons via {@link registerEcaZones}. + */ +export type EcaZone = { + /** Human-readable zone name, e.g. `'North Sea SECA'`. */ + name: string; + /** Bounding boxes whose union approximates the zone's sea extent. */ + bboxes: EcaBbox[]; +}; + +let activeZones: readonly EcaZone[] = []; + +/** + * Register the emission-control-area zones used to compute `properties.ecaKm`. + * Called as a side effect of importing `searoute-ts/eca`; can also be used to + * plug in a custom (e.g. higher-fidelity, full-polygon) zone set. Pass an empty + * array to clear. + */ +export function registerEcaZones(zones: readonly EcaZone[]): void { + activeZones = zones; +} + +/** The currently registered ECA zones (empty until `searoute-ts/eca` is imported). */ +export function getEcaZones(): readonly EcaZone[] { + return activeZones; +} + +/** True when at least one ECA zone has been registered. */ +export function hasEcaZones(): boolean { + return activeZones.length > 0; +} + +function pointInBbox(lon: number, lat: number, bb: EcaBbox): boolean { + return lon >= bb[0] && lon <= bb[2] && lat >= bb[1] && lat <= bb[3]; +} + +/** Bring a (possibly unwrapped) longitude back into [-180, 180). */ +function wrapLon(lon: number): number { + return ((((lon + 180) % 360) + 360) % 360) - 180; +} + +/** + * Kilometres of the path that fall inside any registered ECA zone. + * + * Each segment is subdivided into ~5 km steps; a step counts as "inside" when + * its midpoint falls in any zone bbox, and its geodesic length is accumulated. + * This mirrors the bbox-based passage detection and is intentionally a rough + * figure (see {@link EcaZone}). Returns 0 when no zones are registered. + * + * Coordinates are unwrapped first so that segments crossing the ±180° + * antimeridian interpolate the short way round; each midpoint is wrapped back + * into ±180° before the zone test. + */ +export function ecaDistanceKm(coords: Position[]): number { + const zones = activeZones; + if (zones.length === 0 || coords.length < 2) return 0; + const boxes: EcaBbox[] = zones.flatMap((z) => z.bboxes); + + const u = unwrapCoords(coords); + const STEP_KM = 5; + let inside = 0; + for (let i = 0; i < u.length - 1; i++) { + const a = u[i]; + const b = u[i + 1]; + const segKm = greatCircleKm(a, b); + if (segKm === 0) continue; + const steps = Math.max(1, Math.ceil(segKm / STEP_KM)); + const stepKm = segKm / steps; + for (let s = 0; s < steps; s++) { + const tMid = (s + 0.5) / steps; + const lon = wrapLon(a[0] + (b[0] - a[0]) * tMid); + const lat = a[1] + (b[1] - a[1]) * tMid; + if (boxes.some((bb) => pointInBbox(lon, lat, bb))) inside += stepKm; + } + } + return inside; +} diff --git a/src/lib/emissions.ts b/src/lib/emissions.ts new file mode 100644 index 0000000..a6102ab --- /dev/null +++ b/src/lib/emissions.ts @@ -0,0 +1,49 @@ +/** + * Rough CO₂e estimation. + * + * The estimate is deliberately simple — `co2eTonnes ≈ distanceKm × factor` — + * and is NOT a certified figure. Per-class factors are DERIVED transparently + * from a representative at-sea main-engine fuel burn and service speed: + * + * factorKgPerKm = fuelTonnesPerDay × CO2_PER_TONNE_FUEL × 1000 + * / (24 h × serviceSpeedKnots × 1.852 km/nm) + * + * `CO2_PER_TONNE_FUEL` is the IMO conversion factor for heavy fuel oil (HFO). + * These are order-of-magnitude values for quick comparisons; supply your own + * `co2eFactorKgPerKm` for anything better, and consider the GLEC recommendation + * to inflate shortest-path distance by ~15 % for real-world deviations + * (`glecInflation: 0.15`). + * + * Sources: IMO Fourth GHG Study 2020 (CO₂ conversion factor, typical fuel + * consumption ranges); GLEC Framework v3 (distance-uplift guidance). + */ + +/** Tonnes of CO₂ per tonne of heavy fuel oil burned (IMO Cf for HFO). */ +export const CO2_PER_TONNE_FUEL = 3.114; + +/** A vessel class with the representative figures its CO₂e factor is derived from. */ +export type VesselClassSpec = { + /** Representative at-sea main-engine fuel burn, tonnes/day. */ + fuelTonnesPerDay: number; + /** Representative service speed, knots. */ + serviceSpeedKnots: number; +}; + +/** Named vessel classes accepted by the `vesselClass` option. */ +export type VesselClass = 'feeder' | 'handysize' | 'panamax' | 'postpanamax' | 'capesize' | 'vlcc'; + +/** Representative figures per class (see module doc for the derivation). */ +export const VESSEL_CLASSES: Record = { + feeder: { fuelTonnesPerDay: 40, serviceSpeedKnots: 16 }, + handysize: { fuelTonnesPerDay: 25, serviceSpeedKnots: 13 }, + panamax: { fuelTonnesPerDay: 45, serviceSpeedKnots: 14 }, + postpanamax: { fuelTonnesPerDay: 90, serviceSpeedKnots: 18 }, + capesize: { fuelTonnesPerDay: 55, serviceSpeedKnots: 14 }, + vlcc: { fuelTonnesPerDay: 70, serviceSpeedKnots: 15 }, +}; + +/** Derive the CO₂e emission factor (kg CO₂e per km) for a vessel class. */ +export function co2eFactorKgPerKm(cls: VesselClass): number { + const { fuelTonnesPerDay, serviceSpeedKnots } = VESSEL_CLASSES[cls]; + return (fuelTonnesPerDay * CO2_PER_TONNE_FUEL * 1000) / (24 * serviceSpeedKnots * 1.852); +}