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
75 changes: 73 additions & 2 deletions src/__tests__/scoring.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { computeScores } from "../lib/scoring";
import {
computeScores,
GREEN_IMPACT_FOREST_WEIGHT,
GREEN_IMPACT_POWER_WEIGHT,
PERCENT_MAX,
SCORE_MAX,
SCORE_MIN,
} from "../lib/scoring";

describe("computeScores", () => {
it("perfect data → 100/100", () => {
Expand Down Expand Up @@ -128,7 +135,6 @@ describe("computeScores", () => {
expect(scores.credit_quality).toBe(34);
expect(scores.green_impact).toBe(33);
});
});

// ── Green impact formula edge cases ────────────────────────────────────

Expand Down Expand Up @@ -212,4 +218,69 @@ describe("computeScores", () => {
expect(scores.green_impact).toBe(46);
});
});

// ── Named constants ────────────────────────────────────────────────────

describe("scoring constants", () => {
it("green_impact weights sum to SCORE_MAX", () => {
expect(GREEN_IMPACT_POWER_WEIGHT + GREEN_IMPACT_FOREST_WEIGHT).toBe(SCORE_MAX);
});

it("formula matches the named constants", () => {
const power_output_kw = 800;
const max_power_kw = 1000;
const forest_density_pct = 60;
const expected = Math.round(
(power_output_kw / max_power_kw) * GREEN_IMPACT_POWER_WEIGHT +
(forest_density_pct / PERCENT_MAX) * GREEN_IMPACT_FOREST_WEIGHT,
);
const scores = computeScores({
solar: { efficiency_pct: 80, power_output_kw, max_power_kw },
satellite: { forest_density_pct, ndvi_score: 0.6 },
});
expect(scores.green_impact).toBe(expected);
expect(scores.green_impact).toBeGreaterThanOrEqual(SCORE_MIN);
expect(scores.green_impact).toBeLessThanOrEqual(SCORE_MAX);
});
});

// ── Timestamps ─────────────────────────────────────────────────────────

describe("reading timestamps", () => {
it("accepts timestamps on solar and satellite readings", () => {
const scores = computeScores({
solar: {
efficiency_pct: 80,
power_output_kw: 800,
max_power_kw: 1000,
timestamp: 1_700_000_000_000,
},
satellite: { forest_density_pct: 60, ndvi_score: 0.6, timestamp: 1_700_000_000_000 },
});
expect(scores.credit_quality).toBe(80);
expect(scores.green_impact).toBe(70);
});

it("ignores timestamps: stale and fresh readings score identically", () => {
const solar = { efficiency_pct: 80, power_output_kw: 800, max_power_kw: 1000 };
const satellite = { forest_density_pct: 60, ndvi_score: 0.6 };
const stale = computeScores({
solar: { ...solar, timestamp: 0 },
satellite: { ...satellite, timestamp: 0 },
});
const fresh = computeScores({
solar: { ...solar, timestamp: 1_900_000_000_000 },
satellite: { ...satellite, timestamp: 1_900_000_000_000 },
});
expect(stale).toEqual(fresh);
});

it("timestamps remain optional (existing callers unaffected)", () => {
const scores = computeScores({
solar: { efficiency_pct: 80, power_output_kw: 800, max_power_kw: 1000 },
satellite: { forest_density_pct: 60, ndvi_score: 0.6 },
});
expect(scores.green_impact).toBe(70);
});
});
});
99 changes: 91 additions & 8 deletions src/lib/scoring.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,69 @@
import { logger } from "./logger";

/* ── Scoring constants ────────────────────────────────────────────────────
* Every number the scoring formula depends on lives here, so the formula
* reads as intent rather than arithmetic and a weight change lands in one
* place.
*/

/** Lowest value any impact score can take. */
export const SCORE_MIN = 0;
/** Highest value any impact score can take. */
export const SCORE_MAX = 100;

/** Upper bound of any percentage input (efficiency, forest density). */
export const PERCENT_MAX = 100;

/**
* Share of `green_impact` contributed by solar power output, in points out of
* `SCORE_MAX`. Applied to the power_output / max_power ratio.
*/
export const GREEN_IMPACT_POWER_WEIGHT = 50;

/**
* Share of `green_impact` contributed by forest density, in points out of
* `SCORE_MAX`. Applied to forest_density_pct normalised to 0–1.
*/
export const GREEN_IMPACT_FOREST_WEIGHT = 50;

/** Ratio used when max_power_kw is missing or zero (avoids divide-by-zero). */
export const POWER_RATIO_FALLBACK = 0;

export interface IotInput {
solar: { efficiency_pct: number; power_output_kw: number; max_power_kw: number };
satellite: { forest_density_pct: number; ndvi_score: number };
solar: {
efficiency_pct: number;
power_output_kw: number;
max_power_kw: number;
/**
* Epoch milliseconds the solar reading was produced.
*
* Optional, and intentionally NOT read by `computeScores` — scoring is a
* pure function of the reading's values. It is declared so the freshness
* data returned by `getSolarData` survives the type boundary instead of
* being silently discarded, and so a future staleness check has something
* to read.
*/
timestamp?: number;
};
satellite: {
forest_density_pct: number;
/**
* Normalised vegetation index (0–1).
*
* Kept deliberately: the default `computeScores` formula below scores
* greenery from `forest_density_pct` alone, but the configurable formula
* engine in `scoring-formula.ts` (`computeScoresWithFormula`) weights
* `ndvi_score` through `ndvi_weight`. Dropping the field from this
* interface would break that formula, so it is documented rather than
* removed.
*/
ndvi_score: number;
/**
* Epoch milliseconds the satellite reading was produced.
* Optional and unused by `computeScores` — see `solar.timestamp`.
*/
timestamp?: number;
};
}

export interface ImpactScores {
Expand All @@ -23,15 +84,37 @@ function safeNum(v: number, fallback: number): number {
return v;
}

/**
* Computes the two impact scores for a project reading.
*
* - `credit_quality` — solar panel efficiency percentage, rounded.
* - `green_impact` — a blend of power utilisation and forest density:
* `(power_output / max_power) * GREEN_IMPACT_POWER_WEIGHT +
* (forest_density_pct / PERCENT_MAX) * GREEN_IMPACT_FOREST_WEIGHT`,
* clamped to `SCORE_MIN..SCORE_MAX`.
*
* Reading timestamps are ignored; see `IotInput`.
*/
export function computeScores(input: IotInput): ImpactScores {
const { solar, satellite } = input;
const efficiency = clamp(safeNum(solar.efficiency_pct, 0), 0, 100);
const powerOutput = clamp(safeNum(solar.power_output_kw, 0), 0, Infinity);
const maxPower = clamp(safeNum(solar.max_power_kw, 0), 0, Infinity);
const forestDensity = clamp(safeNum(satellite.forest_density_pct, 0), 0, 100);
const efficiency = clamp(safeNum(solar.efficiency_pct, SCORE_MIN), SCORE_MIN, PERCENT_MAX);
const powerOutput = clamp(safeNum(solar.power_output_kw, SCORE_MIN), SCORE_MIN, Infinity);
const maxPower = clamp(safeNum(solar.max_power_kw, SCORE_MIN), SCORE_MIN, Infinity);
const forestDensity = clamp(
safeNum(satellite.forest_density_pct, SCORE_MIN),
SCORE_MIN,
PERCENT_MAX,
);

const credit_quality = Math.round(efficiency);
const powerRatio = maxPower > 0 ? powerOutput / maxPower : 0;
const green_impact = Math.round(clamp(powerRatio * 50 + (forestDensity / 100) * 50, 0, 100));
const powerRatio = maxPower > 0 ? powerOutput / maxPower : POWER_RATIO_FALLBACK;
const green_impact = Math.round(
clamp(
powerRatio * GREEN_IMPACT_POWER_WEIGHT +
(forestDensity / PERCENT_MAX) * GREEN_IMPACT_FOREST_WEIGHT,
SCORE_MIN,
SCORE_MAX,
),
);
return { credit_quality, green_impact };
}
140 changes: 140 additions & 0 deletions src/types/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
/**
* Typed environment variables.
*
* Declaration merging into `NodeJS.ProcessEnv` gives every `process.env.X`
* access IDE autocomplete and catches typos at compile time (an unknown key
* is an error rather than a silent `undefined`).
*
* Every variable is declared `string | undefined` — the process environment
* is untyped strings and any variable may be absent at runtime, so callers
* must still parse and default. Prefer reading values through `src/config.ts`
* (`config`, `requireEnv`, `numEnv`, …) instead of touching `process.env`
* directly; this declaration exists to make the remaining direct reads safe.
*
* When you add a new variable: declare it here and document it in
* `.env.example`.
*/
declare namespace NodeJS {
interface ProcessEnv {
// ── Runtime ─────────────────────────────────────────────────────────
/** "development" | "test" | "staging" | "production". Default: development */
NODE_ENV?: string;
/** Injected by npm/yarn at run time; used as the APM service version. */
npm_package_version?: string;

// ── Stellar / Soroban ───────────────────────────────────────────────
/** "testnet" | "mainnet". Default: testnet */
STELLAR_NETWORK?: string;
/** Required. Stellar secret key (S…) signing update_impact_score txs. */
ADMIN_SECRET_KEY?: string;
/** Required. Soroban contract address of the ProjectRegistry. */
PROJECT_REGISTRY_CONTRACT_ID?: string;
/** Soroban RPC endpoint. Default: https://soroban-testnet.stellar.org */
RPC_URL?: string;
/** Multichain: Stellar RPC endpoint override. */
STELLAR_RPC_URL?: string;
/** Multichain: Ethereum RPC endpoint; empty disables the chain. */
ETH_RPC_URL?: string;
/** Multichain: Ethereum registry contract address. */
ETH_CONTRACT_ADDRESS?: string;
/** Multichain: Polygon RPC endpoint; empty disables the chain. */
POLYGON_RPC_URL?: string;
/** Multichain: Polygon registry contract address. */
POLYGON_CONTRACT_ADDRESS?: string;

// ── HTTP server ─────────────────────────────────────────────────────
/** Integer port the API listens on. Default: 3001 */
PORT?: string;
/** Origin allowed by CORS. Default: http://localhost:3000 */
FRONTEND_URL?: string;
/** Comma-separated additional CORS origins. */
CORS_ORIGINS?: string;
/** Bearer token for /api/admin/*; unset skips admin auth (dev only). */
ADMIN_API_KEY?: string;
/** Token required to open a /ws connection; falls back to ADMIN_API_KEY. */
WS_AUTH_TOKEN?: string;
/** Integer byte threshold above which responses are compressed. Default: 1024 */
COMPRESSION_THRESHOLD?: string;
/** Integer gzip level 0–9. Default: 6 */
COMPRESSION_LEVEL?: string;
/** Integer ms to wait for in-flight work on shutdown. Default: 30000 */
SHUTDOWN_TIMEOUT_MS?: string;

// ── Database ────────────────────────────────────────────────────────
DB_HOST?: string;
/** Integer port. Default: 5432 */
DB_PORT?: string;
DB_NAME?: string;
DB_USER?: string;
DB_PASSWORD?: string;
/** Integer minimum pooled connections. Default: 2 */
DB_POOL_MIN?: string;
/** Integer maximum pooled connections. Default: 10 */
DB_POOL_MAX?: string;
/** Integer ms to wait for a free connection. Default: 5000 */
DB_POOL_ACQUIRE_TIMEOUT_MS?: string;
/** Integer ms between pool health checks. Default: 30000 */
DB_POOL_HEALTH_CHECK_INTERVAL_MS?: string;

// ── Resilience ──────────────────────────────────────────────────────
/** Integer consecutive RPC failures that open the breaker. Default: 5 */
RPC_BREAKER_FAILURE_THRESHOLD?: string;
/** Integer ms the breaker stays open before a probe. Default: 30000 */
RPC_BREAKER_RECOVERY_TIMEOUT_MS?: string;
/** Integer transaction retry attempts. Default: 4 */
TX_MAX_RETRIES?: string;
/** Integer ms base backoff between retries. Default: 200 */
TX_RETRY_BASE_DELAY_MS?: string;
/** Integer ms cap on retry backoff. Default: 10000 */
TX_RETRY_MAX_DELAY_MS?: string;

// ── Cron & IoT ──────────────────────────────────────────────────────
/** IANA timezone for cron/hourly seed boundaries. Default: UTC */
CRON_TIMEZONE?: string;
/** Float 0–1 failure ratio that marks a cron run unhealthy. Default: 0.5 */
CRON_FAILURE_THRESHOLD?: string;
/** "true" disables the in-memory IoT reading cache. */
IOT_CACHE_DISABLED?: string;
/** Integer ms satellite readings stay cached. Default: 7200000 */
SATELLITE_CACHE_TTL_MS?: string;
/** Integer consecutive source failures before alerting. Default: 3 */
SATELLITE_ALERT_THRESHOLD?: string;

// ── Rate limiting & access control ──────────────────────────────────
/** Integer ms public rate-limit window. Default: 60000 */
RATE_LIMIT_WINDOW_MS?: string;
/** Integer max public requests per window per IP. Default: 100 */
RATE_LIMIT_MAX?: string;
/** Integer ms admin rate-limit window. Default: 60000 */
RATE_LIMIT_ADMIN_WINDOW_MS?: string;
/** Integer max admin requests per window per IP. Default: 20 */
RATE_LIMIT_ADMIN_MAX?: string;
/** Comma-separated IPs/CIDRs allowed on admin routes; empty disables. */
ADMIN_IP_WHITELIST?: string;
/** "false" stops private/internal ranges bypassing the whitelist. */
ADMIN_IP_WHITELIST_BYPASS_PRIVATE?: string;
/** HMAC secret for request signature verification; empty disables. */
REQUEST_SIGNING_SECRET?: string;
/** Secrets backend: "env" | provider name. Default: env */
SECRETS_PROVIDER?: string;

// ── Logging & APM ───────────────────────────────────────────────────
/** "debug" | "info" | "warn" | "error". Default: derived from NODE_ENV */
LOG_LEVEL?: string;
/** "datadog" | "newrelic" | "opentelemetry" | "none". Default: none */
APM_PROVIDER?: string;
DD_SERVICE?: string;
DD_ENV?: string;
DD_VERSION?: string;
DD_AGENT_HOST?: string;
NEW_RELIC_LICENSE_KEY?: string;
NEW_RELIC_APP_NAME?: string;
OTEL_SERVICE_NAME?: string;
OTEL_EXPORTER_OTLP_ENDPOINT?: string;
/** "false" disables the OTLP exporter. */
OTEL_EXPORTER_OTLP_ENABLED?: string;
OTEL_ZIPKIN_ENDPOINT?: string;
/** "true" enables the Zipkin exporter. */
OTEL_ZIPKIN_ENABLED?: string;
}
}
Loading