Skip to content
Open
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand Down
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
```
Expand All @@ -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)
});
```

Expand Down Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
59 changes: 59 additions & 0 deletions src/eca/data.ts
Original file line number Diff line number Diff line change
@@ -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;
101 changes: 101 additions & 0 deletions src/eca/index.spec.ts
Original file line number Diff line number Diff line change
@@ -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<Point> {
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);
});
17 changes: 17 additions & 0 deletions src/eca/index.ts
Original file line number Diff line number Diff line change
@@ -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);
64 changes: 64 additions & 0 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Feature, Point } from 'geojson';
import {
CANAL_MAX_DRAFT_M,
clearFinderCache,
co2eFactorKgPerKm,
DEFAULT_MARNET,
loadNetwork,
NoRouteError,
Expand Down Expand Up @@ -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) => {
Expand Down
Loading
Loading