From 6826ebf3f4b11d92e1c800517a4499b19471e8e1 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 09:55:55 +0200 Subject: [PATCH 001/132] feat(wind-farm): add the ids, theme, and day and operating model --- explainers/wind-farm/src/ids.ts | 176 ++++++++++++++++++++ explainers/wind-farm/src/model/constants.ts | 48 ++++++ explainers/wind-farm/src/model/control.ts | 141 ++++++++++++++++ explainers/wind-farm/src/model/curves.ts | 54 ++++++ explainers/wind-farm/src/model/day.ts | 104 ++++++++++++ explainers/wind-farm/src/model/index.ts | 5 + explainers/wind-farm/src/model/layout.ts | 94 +++++++++++ explainers/wind-farm/src/theme.ts | 53 ++++++ 8 files changed, 675 insertions(+) create mode 100644 explainers/wind-farm/src/ids.ts create mode 100644 explainers/wind-farm/src/model/constants.ts create mode 100644 explainers/wind-farm/src/model/control.ts create mode 100644 explainers/wind-farm/src/model/curves.ts create mode 100644 explainers/wind-farm/src/model/day.ts create mode 100644 explainers/wind-farm/src/model/index.ts create mode 100644 explainers/wind-farm/src/model/layout.ts create mode 100644 explainers/wind-farm/src/theme.ts diff --git a/explainers/wind-farm/src/ids.ts b/explainers/wind-farm/src/ids.ts new file mode 100644 index 00000000..c2535d81 --- /dev/null +++ b/explainers/wind-farm/src/ids.ts @@ -0,0 +1,176 @@ +export const SCENE_IDS = ['farm', 'turbine'] as const; + +export type SceneId = (typeof SCENE_IDS)[number]; + +export const TURBINE_PART_IDS = [ + 'land', + 'foundation', + 'tower', + 'transformer', + 'towerCable', + 'nacelle', + 'cooler', + 'bedplate', + 'hub', + 'blades', + 'pitchCylinders', + 'mainBearing', + 'mainShaft', + 'gearbox', + 'brakeDisc', + 'generator', + 'converter', + 'yawDrives', + 'sweptArea', + 'streamlinesGroup', + 'heroWake', +] as const; + +export const FARM_PART_IDS = [ + 'farmLand', + 'farmTurbines', + 'accessRoads', + 'spacingMarker', + 'prevailingWind', + 'windArrows', + 'shearProfile', + 'wakePlumes', + 'collectorCables', + 'substation', + 'gridLine', +] as const; + +export const PART_IDS = [...TURBINE_PART_IDS, ...FARM_PART_IDS] as const; + +export type PartId = (typeof PART_IDS)[number]; + +export const SCENE_PARTS: Readonly> = { + turbine: TURBINE_PART_IDS, + farm: FARM_PART_IDS, +}; + +export const PHASE_IDS = ['night', 'morning', 'afternoon', 'storm', 'evening'] as const; + +export type PhaseId = (typeof PHASE_IDS)[number]; + +export const PRESET_IDS = ['farm', 'tower', 'nacelle', 'curve', 'wakes', 'grid'] as const; + +export type PresetId = (typeof PRESET_IDS)[number]; + +export const PRESET_SCENES: Readonly> = { + farm: 'farm', + tower: 'turbine', + nacelle: 'turbine', + curve: 'turbine', + wakes: 'farm', + grid: 'farm', +}; + +export const SITE_WIND_IDS = ['calm', 'typical', 'windy'] as const; + +export type SiteWindId = (typeof SITE_WIND_IDS)[number]; + +export const SPACING_OPTIONS = [5, 7, 9] as const; + +export type SpacingD = (typeof SPACING_OPTIONS)[number]; + +export const SPACING_CHOICE_IDS = ['5', '7', '9'] as const; + +export type SpacingChoiceId = (typeof SPACING_CHOICE_IDS)[number]; + +export const OPERATING_STATE_IDS = [ + 'idle', + 'partial', + 'full', + 'rampDown', + 'stopping', + 'parked', + 'starting', +] as const; + +export type OperatingStateId = (typeof OPERATING_STATE_IDS)[number]; + +export const NACELLE_CHOICE_IDS = ['closed', 'open'] as const; + +export type NacelleChoiceId = (typeof NACELLE_CHOICE_IDS)[number]; + +export const WIND_PRESET_IDS = ['cutIn', 'peak', 'rated', 'rampDown', 'cutOut'] as const; + +export type WindPresetId = (typeof WIND_PRESET_IDS)[number]; + +export const FOLLOW_DAY = 'day'; + +export const WIND_AT_IDS = [FOLLOW_DAY, ...WIND_PRESET_IDS] as const; + +export type WindAtId = (typeof WIND_AT_IDS)[number]; + +export const CHOICE_IDS = ['siteWind'] as const; + +export type ChoiceId = (typeof CHOICE_IDS)[number]; + +export const CHAPTER_ACTION_IDS = ['siteWind', 'spacing', 'nacelle', 'windAt'] as const; + +export type ChapterActionId = (typeof CHAPTER_ACTION_IDS)[number]; + +export const VIEW_IDS = ['streamlines', 'wakes', 'cables', 'labels', 'cutaway'] as const; + +export type ViewId = (typeof VIEW_IDS)[number]; + +export type ViewOptions = Record; + +export type CameraView = + | 'farmAerial' + | 'turbineTall' + | 'nacelleCutaway' + | 'rotorQuarter' + | 'wakeStreaks' + | 'gridSubstation'; + +export type RegionId = 'farm' | 'wakes' | 'grid' | 'turbine' | 'nacelle' | 'rotor'; + +export type AnchorId = 'hub' | 'yawPivot' | 'towerBase' | 'farmCentre' | 'heroSite' | 'substation'; + +export type Point = readonly [x: number, y: number, z: number]; + +export type RowIndex = 0 | 1 | 2; + +export interface FarmSite { + row: RowIndex; + column: number; + x: number; + z: number; +} + +export interface WindReading { + speed: number; + fromDeg: number; + induction: number; +} + +export interface RotorReading { + rpm: number; + pitchDeg: number; + yawDeg: number; + state: OperatingStateId; + braked: boolean; + powerShare: number; +} + +export interface FarmReading { + spacing: SpacingD; + sites: readonly FarmSite[]; + deficits: readonly number[]; + plumeLengthD: number; + plumeStrength: number; + outputShare: number; +} + +export interface AssemblyState { + scene: SceneId; + phase: number; + playing: boolean; + wind: WindReading; + rotor: RotorReading; + farm: FarmReading; + view: ViewOptions; +} diff --git a/explainers/wind-farm/src/model/constants.ts b/explainers/wind-farm/src/model/constants.ts new file mode 100644 index 00000000..7d124190 --- /dev/null +++ b/explainers/wind-farm/src/model/constants.ts @@ -0,0 +1,48 @@ +export const ROTOR_DIAMETER_M = 150; +export const ROTOR_RADIUS_M = ROTOR_DIAMETER_M / 2; +export const HUB_HEIGHT_M = 105; +export const TIP_HEIGHT_M = HUB_HEIGHT_M + ROTOR_RADIUS_M; +export const BLADE_LENGTH_M = 73.7; +export const MAX_CHORD_M = 4.2; +export const SWEPT_AREA_M2 = 17_671; +export const AIR_DENSITY = 1.225; + +export const RATED_KW = 4200; +export const CUT_IN_MS = 3; +export const RATED_WIND_MS = 12; +export const RAMP_START_MS = 20; +export const CUT_OUT_MS = 24.5; +export const RESTART_MS = 22.5; +export const MAX_RPM = 10.4; +export const GEAR_RATIO = 143; +export const MODEL_TIP_SPEED_RATIO = 8; +export const FEATHER_DEG = 90; +export const STOP_MINUTES = 20; +export const START_MINUTES = 20; +export const BETZ_LIMIT = 16 / 27; + +export const TURBINE_COUNT = 27; +export const FARM_RATED_KW = TURBINE_COUNT * RATED_KW; +export const ACROSS_SPACING_D = 4; +export const DEFAULT_SPACING_D = 7; +export const WAKE_DECAY = 0.075; +export const PLUME_VISIBLE_DEFICIT = 0.03; +export const MAX_PLUME_D = 20; +export const SHEAR_EXPONENT = 1 / 7; + +export const GENERATOR_VOLTS = 800; +export const COLLECTOR_KV = 36; +export const GRID_KV = 110; + +export const HOME_KWH_PER_YEAR = 10_791; +export const HOURS_PER_YEAR = 8760; +export const ANNUAL_MWH_PER_TURBINE = 14_692; +export const CARBON_G_PER_KWH = 7.3; +export const PAYBACK_MONTHS = 7.6; +export const DESIGN_LIFE_YEARS = 20; +export const BUILT_HA_PER_MW = 0.3; +export const PROJECT_HA_PER_MW = 34; +export const FOOTBALL_PITCH_M2 = 7140; + +export const WIND_FROM_DEG = 270; +export const VEER_DEG = 6; diff --git a/explainers/wind-farm/src/model/control.ts b/explainers/wind-farm/src/model/control.ts new file mode 100644 index 00000000..253292dc --- /dev/null +++ b/explainers/wind-farm/src/model/control.ts @@ -0,0 +1,141 @@ +import { lerp, smoothstep } from '@core/math'; +import type { OperatingStateId, SiteWindId } from '../ids'; +import { + CUT_IN_MS, + CUT_OUT_MS, + FEATHER_DEG, + GEAR_RATIO, + MAX_RPM, + MODEL_TIP_SPEED_RATIO, + RAMP_START_MS, + RATED_WIND_MS, + RESTART_MS, + ROTOR_DIAMETER_M, + START_MINUTES, + STOP_MINUTES, +} from './constants'; +import { PITCH_KNOTS, interpolateKnots } from './curves'; +import { CYCLE_MINUTES, dayWind, wrapMinute } from './day'; + +export interface OperatingReading { + state: OperatingStateId; + rpm: number; + pitchDeg: number; + braked: boolean; + producing: boolean; +} + +export interface ParkedWindow { + stop: number; + restart: number; +} + +const SECONDS_PER_MINUTE = 60; +const FULL_TURN_RAD = 2 * Math.PI; +const FEATHER_SHARE = 0.6; +const SPIN_DOWN_SHARE = 0.8; +const SPIN_UP_START_SHARE = 0.2; +const PRODUCING_STATES: readonly OperatingStateId[] = ['partial', 'full', 'rampDown']; + +export function runningRpm(wind: number): number { + const followRpm = + (MODEL_TIP_SPEED_RATIO * Math.max(0, wind) * SECONDS_PER_MINUTE) / (Math.PI * ROTOR_DIAMETER_M); + return Math.min(MAX_RPM, followRpm); +} + +export function tipSpeed(rpm: number): number { + return (Math.PI * ROTOR_DIAMETER_M * rpm) / SECONDS_PER_MINUTE; +} + +export function generatorRpm(rpm: number): number { + return rpm * GEAR_RATIO; +} + +export function runningPitchDeg(wind: number): number { + return interpolateKnots(PITCH_KNOTS, wind); +} + +export function runningState(wind: number): OperatingStateId { + if (wind < CUT_IN_MS) return 'idle'; + if (wind < RATED_WIND_MS) return 'partial'; + if (wind <= RAMP_START_MS) return 'full'; + if (wind < CUT_OUT_MS) return 'rampDown'; + return 'parked'; +} + +function reading( + state: OperatingStateId, + rpm: number, + pitchDeg: number, + braked: boolean, +): OperatingReading { + return { state, rpm, pitchDeg, braked, producing: PRODUCING_STATES.includes(state) }; +} + +function runningAt(wind: number): OperatingReading { + const state = runningState(wind); + if (state === 'parked') return parkedReading(); + return reading(state, runningRpm(wind), runningPitchDeg(wind), false); +} + +function parkedReading(): OperatingReading { + return reading('parked', 0, FEATHER_DEG, true); +} + +export function operatingAtWind(wind: number): OperatingReading { + return runningAt(wind); +} + +function findParkedWindow(site: SiteWindId): ParkedWindow | null { + let stop: number | null = null; + for (let minute = 0; minute < CYCLE_MINUTES; minute += 1) { + const wind = dayWind(minute, site); + if (stop === null && wind >= CUT_OUT_MS) stop = minute; + else if (stop !== null && wind < RESTART_MS) return { stop, restart: minute }; + } + return stop === null ? null : { stop, restart: CYCLE_MINUTES }; +} + +const parkedWindows = new Map(); + +export function parkedWindow(site: SiteWindId): ParkedWindow | null { + if (!parkedWindows.has(site)) parkedWindows.set(site, findParkedWindow(site)); + return parkedWindows.get(site) ?? null; +} + +function stoppingAt(progress: number, windAtStop: number): OperatingReading { + const pitchDeg = lerp( + runningPitchDeg(windAtStop), + FEATHER_DEG, + smoothstep(progress, 0, FEATHER_SHARE), + ); + const rpm = runningRpm(windAtStop) * (1 - smoothstep(progress, 0, SPIN_DOWN_SHARE)); + return reading('stopping', rpm, pitchDeg, progress >= SPIN_DOWN_SHARE); +} + +function startingAt(progress: number, wind: number): OperatingReading { + const pitchDeg = lerp(FEATHER_DEG, runningPitchDeg(wind), smoothstep(progress, 0, FEATHER_SHARE)); + const rpm = runningRpm(wind) * smoothstep(progress, SPIN_UP_START_SHARE, 1); + return reading('starting', rpm, pitchDeg, false); +} + +export function operatingAt(minute: number, site: SiteWindId): OperatingReading { + const clock = wrapMinute(minute); + const wind = dayWind(clock, site); + const window = parkedWindow(site); + if (window === null || clock < window.stop) return runningAt(wind); + const parkedFrom = window.stop + STOP_MINUTES; + if (clock < parkedFrom) { + return stoppingAt((clock - window.stop) / STOP_MINUTES, dayWind(window.stop, site)); + } + if (clock < window.restart) return parkedReading(); + if (clock < window.restart + START_MINUTES) { + return startingAt((clock - window.restart) / START_MINUTES, wind); + } + return runningAt(wind); +} + +export function advanceAzimuth(azimuth: number, rpm: number, deltaSeconds: number): number { + const next = azimuth + (rpm / SECONDS_PER_MINUTE) * FULL_TURN_RAD * deltaSeconds; + return ((next % FULL_TURN_RAD) + FULL_TURN_RAD) % FULL_TURN_RAD; +} diff --git a/explainers/wind-farm/src/model/curves.ts b/explainers/wind-farm/src/model/curves.ts new file mode 100644 index 00000000..0314ee9c --- /dev/null +++ b/explainers/wind-farm/src/model/curves.ts @@ -0,0 +1,54 @@ +export type Knot = readonly [x: number, y: number]; + +export function interpolateKnots(knots: readonly Knot[], x: number): number { + const first = knots[0]; + const last = knots[knots.length - 1]; + if (x <= first[0]) return first[1]; + if (x >= last[0]) return last[1]; + const upper = knots.findIndex(([knotX]) => knotX >= x); + const [x0, y0] = knots[upper - 1]; + const [x1, y1] = knots[upper]; + return y0 + ((y1 - y0) * (x - x0)) / (x1 - x0); +} + +export const POWER_CURVE: readonly Knot[] = [ + [3, 81], + [4, 285], + [5, 597], + [6, 1062], + [7, 1709], + [8, 2545], + [9, 3458], + [10, 4038], + [11, 4191], + [12, 4200], + [20, 4200], + [20.5, 4186], + [21, 3870], + [22, 2745], + [23, 1805], + [24, 1283], + [24.5, 1116], +]; + +export const THRUST_CURVE: readonly Knot[] = [ + [4, 0.83], + [5, 0.82], + [6, 0.82], + [7, 0.8], + [8, 0.79], + [9, 0.7], + [10, 0.54], + [11, 0.39], + [12, 0.29], + [15, 0.15], + [20, 0.06], +]; + +export const PITCH_KNOTS: readonly Knot[] = [ + [11, 0], + [12, 4], + [15, 12], + [20, 20], + [24.5, 27], +]; diff --git a/explainers/wind-farm/src/model/day.ts b/explainers/wind-farm/src/model/day.ts new file mode 100644 index 00000000..2c78e255 --- /dev/null +++ b/explainers/wind-farm/src/model/day.ts @@ -0,0 +1,104 @@ +import { PHASE_IDS, SITE_WIND_IDS } from '../ids'; +import type { PhaseId, SiteWindId, WindPresetId } from '../ids'; +import { CUT_IN_MS, RATED_WIND_MS, VEER_DEG, WIND_FROM_DEG } from './constants'; +import { interpolateKnots } from './curves'; +import type { Knot } from './curves'; + +export const MINUTES_PER_HOUR = 60; +export const CYCLE_MINUTES = 24 * MINUTES_PER_HOUR; + +export const PHASE_RANGES: Readonly> = { + night: [0, 360], + morning: [360, 690], + afternoon: [690, 960], + storm: [960, 1148], + evening: [1148, CYCLE_MINUTES], +}; + +export const BASE_WIND_KNOTS: readonly Knot[] = [ + [0, 2.5], + [300, 2.5], + [360, 3], + [600, 9], + [690, 12], + [900, 16], + [960, 18], + [1000, 26], + [1100, 26], + [1148, 18], + [1200, 13], + [1320, 7], + [CYCLE_MINUTES, 2.5], +]; + +export const STORM_CORE = [1000, 1100] as const; + +export const SITE_WIND_SCALE: Readonly> = { + calm: 0.8, + typical: 1, + windy: 1.15, +}; + +const PEAK_EFFICIENCY_MS = 8; +const PARKED_DEMO_MS = 25; +const RAMP_DEMO_MS = 23; + +export const WIND_PRESETS: Readonly> = { + cutIn: CUT_IN_MS, + peak: PEAK_EFFICIENCY_MS, + rated: RATED_WIND_MS, + rampDown: RAMP_DEMO_MS, + cutOut: PARKED_DEMO_MS, +}; + +export const DEFAULT_SPEED = 12; +export const SPEED_RANGE = { min: 1, max: 60, step: 1, default: DEFAULT_SPEED } as const; + +const SECONDS_PER_MINUTE = 60; + +export function rate(speed: number): number { + return (speed * MINUTES_PER_HOUR) / SECONDS_PER_MINUTE; +} + +export function wrapMinute(minute: number): number { + return ((minute % CYCLE_MINUTES) + CYCLE_MINUTES) % CYCLE_MINUTES; +} + +export function phaseOf(minute: number): PhaseId { + const wrapped = wrapMinute(minute); + return PHASE_IDS.find((id) => wrapped < PHASE_RANGES[id][1]) ?? PHASE_IDS[PHASE_IDS.length - 1]; +} + +function twoDigits(value: number): string { + return String(value).padStart(2, '0'); +} + +export function clockOf(minute: number): string { + const whole = Math.floor(wrapMinute(minute)); + return `${twoDigits(Math.floor(whole / MINUTES_PER_HOUR))}:${twoDigits(whole % MINUTES_PER_HOUR)}`; +} + +function inStormCore(minute: number): boolean { + return minute >= STORM_CORE[0] && minute <= STORM_CORE[1]; +} + +function siteKnots(scale: number): readonly Knot[] { + return BASE_WIND_KNOTS.map(([minute, wind]) => [ + minute, + inStormCore(minute) ? wind : wind * scale, + ]); +} + +const SITE_KNOTS: Readonly> = Object.fromEntries( + SITE_WIND_IDS.map((site) => [site, siteKnots(SITE_WIND_SCALE[site])]), +) as Record; + +export function dayWind(minute: number, site: SiteWindId): number { + return interpolateKnots(SITE_KNOTS[site], wrapMinute(minute)); +} + +const FULL_TURN_RAD = 2 * Math.PI; + +export function windFromDeg(minute: number): number { + return WIND_FROM_DEG + VEER_DEG * Math.sin((FULL_TURN_RAD * wrapMinute(minute)) / CYCLE_MINUTES); +} diff --git a/explainers/wind-farm/src/model/index.ts b/explainers/wind-farm/src/model/index.ts new file mode 100644 index 00000000..4dfa8735 --- /dev/null +++ b/explainers/wind-farm/src/model/index.ts @@ -0,0 +1,5 @@ +export * from './constants'; +export * from './curves'; +export * from './day'; +export * from './layout'; +export * from './control'; diff --git a/explainers/wind-farm/src/model/layout.ts b/explainers/wind-farm/src/model/layout.ts new file mode 100644 index 00000000..41afb58b --- /dev/null +++ b/explainers/wind-farm/src/model/layout.ts @@ -0,0 +1,94 @@ +import type { FarmSite, Point, RowIndex, SpacingD } from '../ids'; +import { ACROSS_SPACING_D, HUB_HEIGHT_M, ROTOR_DIAMETER_M, TIP_HEIGHT_M } from './constants'; + +export type GroundPoint = readonly [x: number, z: number]; + +const HILL_HEIGHT_M = 18; +const HILL_WAVE_X_M = 900; +const HILL_PHASE = 0.6; +const HILL_WAVE_Z_M = 1300; +const RIPPLE_HEIGHT_M = 9; +const RIPPLE_WAVE_M = 520; + +export function terrainHeight(x: number, z: number): number { + return ( + HILL_HEIGHT_M * Math.sin(x / HILL_WAVE_X_M + HILL_PHASE) * Math.cos(z / HILL_WAVE_Z_M) + + RIPPLE_HEIGHT_M * Math.sin((x + z) / RIPPLE_WAVE_M) + ); +} + +export const ROW_COUNT = 3; +export const COLUMN_COUNT = 9; +const MIDDLE_ROW = 1; +const MIDDLE_COLUMN = 4; +const STAGGER_M = 150; + +export const HERO_SITE = MIDDLE_COLUMN; + +export function farmLayout(spacing: SpacingD): readonly FarmSite[] { + const rowStep = spacing * ROTOR_DIAMETER_M; + const columnStep = ACROSS_SPACING_D * ROTOR_DIAMETER_M; + return Array.from({ length: ROW_COUNT * COLUMN_COUNT }, (_, index) => { + const row = Math.floor(index / COLUMN_COUNT) as RowIndex; + const column = index % COLUMN_COUNT; + const stagger = row === MIDDLE_ROW ? STAGGER_M : -STAGGER_M; + return { + row, + column, + x: (row - MIDDLE_ROW) * rowStep, + z: (column - MIDDLE_COLUMN) * columnStep + stagger, + }; + }); +} + +export const SUBSTATION = { x: 2200, z: 3000, width: 120, depth: 80 } as const; +export const GRID_LINE_END = { x: 6000, z: 3000 } as const; +export const PYLON_SPACING_M = 400; +export const FARM_TERRAIN = { minX: -4000, maxX: 6000, minZ: -5000, maxZ: 5000 } as const; + +export function collectorRoutes(spacing: SpacingD): readonly (readonly GroundPoint[])[] { + const sites = farmLayout(spacing); + return Array.from({ length: ROW_COUNT }, (_, row) => { + const rowSites = sites.filter((site) => site.row === row).sort((a, b) => a.z - b.z); + const route: GroundPoint[] = rowSites.map((site) => [site.x, site.z]); + route.push([SUBSTATION.x, SUBSTATION.z]); + return route; + }); +} + +const WIND_ARROW_OFFSET_M = 600; +const SHEAR_OFFSET_M = 400; + +export function windArrowsX(spacing: SpacingD): number { + return -spacing * ROTOR_DIAMETER_M - WIND_ARROW_OFFSET_M; +} + +export function shearProfileAt(spacing: SpacingD): GroundPoint { + return [-spacing * ROTOR_DIAMETER_M - SHEAR_OFFSET_M, -STAGGER_M]; +} + +export const SHEAR_HEIGHTS_M = [30, 55, 80, HUB_HEIGHT_M, 130, 155, TIP_HEIGHT_M] as const; + +export const TURBINE_LAND = { radius: 3000, flatRadius: 60, blendRadius: 200 } as const; + +export const TURBINE_GEOMETRY = { + towerBaseDiameter: 4.5, + towerTopDiameter: 3.0, + towerTopY: 103.3, + nacelle: { minX: -4.4, maxX: 8.4, minY: 103.3, maxY: 106.7, halfWidth: 2.1 }, + cooler: { minX: 4.0, maxX: 8.4, topY: 110.2 }, + shaftY: HUB_HEIGHT_M, + hub: [-7.0, HUB_HEIGHT_M, 0] as Point, + spinnerRadius: 1.9, + bladeRootRadius: 1.3, + mainBearing: { x: -3.6, radius: 1.2 }, + mainShaft: { minX: -4.4, maxX: -1.0, radius: 0.6 }, + gearbox: { minX: -1.0, maxX: 2.4, radius: 1.4 }, + brakeDisc: { x: 2.6, radius: 0.9 }, + generator: { minX: 3.2, maxX: 5.6, radius: 0.9 }, + converter: { minX: 6.0, maxX: 8.0, wallZ: -2.1 }, + bedplate: { minX: -4.4, maxX: 2.4, y: 103.5 }, + yawDrives: { y: 103.3, radius: 1.9 }, + transformer: { centre: [8, 0, 8] as Point, size: [3, 2.5, 3] as Point }, + foundationRadius: 5, +} as const; diff --git a/explainers/wind-farm/src/theme.ts b/explainers/wind-farm/src/theme.ts new file mode 100644 index 00000000..c3679adf --- /dev/null +++ b/explainers/wind-farm/src/theme.ts @@ -0,0 +1,53 @@ +import { THEME as CORE_THEME } from '@core/theme'; +import type { OperatingStateId, PhaseId } from './ids'; + +export const THEME = { + ...CORE_THEME, + night: '#3d5a99', + morning: '#6fbf73', + afternoon: '#f2b134', + storm: '#e5534b', + evening: '#9b7fd1', + haze: '#cfdbe6', + skyTop: '#7fa9d6', + grass: '#8fae5d', + grassDark: '#6f8f45', + field: '#c9b876', + trees: '#4f6b3a', + gravel: '#bdb5a6', + turbineWhite: '#eef0f2', + turbineGrey: '#c9ced3', + concrete: '#b9b6ae', + castIron: '#5d6168', + steel: '#8c939b', + gearOil: '#c58b3a', + copper: '#b87333', + converterCabinet: '#d7dbe0', + hydraulic: '#d9c23f', + brakeGlow: '#ff6a3d', + cooler: '#aeb6bd', + wind: '#5fa8ff', + wake: '#2f4f6f', + cable: '#ffb347', + gridLine: '#3a3f46', + substation: '#9aa3ad', + spacing: '#ffffff', +} as const; + +export const PHASE_TONES: Readonly> = { + night: THEME.night, + morning: THEME.morning, + afternoon: THEME.afternoon, + storm: THEME.storm, + evening: THEME.evening, +}; + +export const STATE_TONES: Readonly> = { + idle: THEME.night, + partial: THEME.morning, + full: THEME.afternoon, + rampDown: THEME.storm, + stopping: THEME.storm, + parked: THEME.castIron, + starting: THEME.morning, +}; From 65ad029762f8a465290b529bef7af02d129f44ad Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 09:58:25 +0200 Subject: [PATCH 002/132] test(wind-farm): cover the ids, theme, day and operating model --- explainers/wind-farm/src/ids.test.ts | 45 +++++++ .../wind-farm/src/model/constants.test.ts | 36 ++++++ .../wind-farm/src/model/control.test.ts | 118 ++++++++++++++++++ explainers/wind-farm/src/model/curves.test.ts | 44 +++++++ explainers/wind-farm/src/model/day.test.ts | 88 +++++++++++++ explainers/wind-farm/src/model/layout.test.ts | 70 +++++++++++ explainers/wind-farm/src/theme.test.ts | 33 +++++ 7 files changed, 434 insertions(+) create mode 100644 explainers/wind-farm/src/ids.test.ts create mode 100644 explainers/wind-farm/src/model/constants.test.ts create mode 100644 explainers/wind-farm/src/model/control.test.ts create mode 100644 explainers/wind-farm/src/model/curves.test.ts create mode 100644 explainers/wind-farm/src/model/day.test.ts create mode 100644 explainers/wind-farm/src/model/layout.test.ts create mode 100644 explainers/wind-farm/src/theme.test.ts diff --git a/explainers/wind-farm/src/ids.test.ts b/explainers/wind-farm/src/ids.test.ts new file mode 100644 index 00000000..ab14786b --- /dev/null +++ b/explainers/wind-farm/src/ids.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest'; +import { + FARM_PART_IDS, + FOLLOW_DAY, + PART_IDS, + PRESET_IDS, + PRESET_SCENES, + SCENE_IDS, + SCENE_PARTS, + SPACING_CHOICE_IDS, + SPACING_OPTIONS, + TURBINE_PART_IDS, + WIND_AT_IDS, + WIND_PRESET_IDS, +} from './ids'; + +describe('ids', () => { + it('names every part once', () => { + expect(PART_IDS).toHaveLength(TURBINE_PART_IDS.length + FARM_PART_IDS.length); + expect(new Set(PART_IDS).size).toBe(PART_IDS.length); + }); + + it('splits the parts between the two scenes with no overlap', () => { + expect(Object.keys(SCENE_PARTS).sort()).toEqual([...SCENE_IDS].sort()); + const scenePartIds = SCENE_IDS.flatMap((scene) => [...SCENE_PARTS[scene]]); + expect(scenePartIds).toHaveLength(PART_IDS.length); + expect(new Set(scenePartIds)).toEqual(new Set(PART_IDS)); + }); + + it('places every preset in a scene', () => { + expect(Object.keys(PRESET_SCENES)).toEqual([...PRESET_IDS]); + PRESET_IDS.forEach((preset) => { + expect(SCENE_IDS).toContain(PRESET_SCENES[preset]); + }); + }); + + it('offers following the day before the wind presets', () => { + expect(WIND_AT_IDS[0]).toBe('day'); + expect(WIND_AT_IDS).toEqual([FOLLOW_DAY, ...WIND_PRESET_IDS]); + }); + + it('labels each spacing choice with its rotor diameters', () => { + expect(SPACING_CHOICE_IDS).toEqual(SPACING_OPTIONS.map(String)); + }); +}); diff --git a/explainers/wind-farm/src/model/constants.test.ts b/explainers/wind-farm/src/model/constants.test.ts new file mode 100644 index 00000000..3a4e4d7d --- /dev/null +++ b/explainers/wind-farm/src/model/constants.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest'; +import { + BETZ_LIMIT, + CUT_IN_MS, + CUT_OUT_MS, + FARM_RATED_KW, + RAMP_START_MS, + RATED_WIND_MS, + RESTART_MS, + ROTOR_RADIUS_M, + TIP_HEIGHT_M, + TURBINE_COUNT, +} from './constants'; + +describe('constants', () => { + it('puts the blade tip 180 m above the ground', () => { + expect(ROTOR_RADIUS_M).toBe(75); + expect(TIP_HEIGHT_M).toBe(180); + }); + + it('rates the 27 turbine farm at 113,400 kW', () => { + expect(TURBINE_COUNT).toBe(27); + expect(FARM_RATED_KW).toBe(113_400); + }); + + it('caps the share of wind power at the Betz limit of 0.593', () => { + expect(BETZ_LIMIT).toBeCloseTo(0.593, 3); + }); + + it('orders the wind thresholds from cut-in to cut-out', () => { + expect(CUT_IN_MS).toBeLessThan(RATED_WIND_MS); + expect(RATED_WIND_MS).toBeLessThan(RAMP_START_MS); + expect(RAMP_START_MS).toBeLessThan(RESTART_MS); + expect(RESTART_MS).toBeLessThan(CUT_OUT_MS); + }); +}); diff --git a/explainers/wind-farm/src/model/control.test.ts b/explainers/wind-farm/src/model/control.test.ts new file mode 100644 index 00000000..1da3a7e2 --- /dev/null +++ b/explainers/wind-farm/src/model/control.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { + advanceAzimuth, + generatorRpm, + operatingAt, + operatingAtWind, + parkedWindow, + runningPitchDeg, + runningRpm, + runningState, + tipSpeed, +} from './control'; +import { dayWind } from './day'; + +const FULL_TURN_RAD = 2 * Math.PI; + +describe('running turbine', () => { + it('follows the wind with the rotor speed up to 10.4 rpm', () => { + expect(runningRpm(8)).toBeCloseTo(8.15, 2); + expect(runningRpm(15)).toBe(10.4); + expect(runningRpm(-1)).toBe(0); + }); + + it('moves the blade tip at about 82 m/s at full speed', () => { + expect(tipSpeed(10.4)).toBeCloseTo(81.68, 2); + }); + + it('gears the rotor up to about 1487 rpm at the generator', () => { + expect(generatorRpm(10.4)).toBeCloseTo(1487.2, 9); + }); + + it('pitches the blades only above 11 m/s', () => { + expect(runningPitchDeg(10)).toBe(0); + expect(runningPitchDeg(15)).toBe(12); + }); + + it('names the state by the wind speed', () => { + expect(runningState(2.9)).toBe('idle'); + expect(runningState(3)).toBe('partial'); + expect(runningState(12)).toBe('full'); + expect(runningState(20)).toBe('full'); + expect(runningState(20.5)).toBe('rampDown'); + expect(runningState(24.5)).toBe('parked'); + }); +}); + +describe('parkedWindow', () => { + it('stops in the storm and restarts as it eases at every site', () => { + expect(parkedWindow('typical')).toEqual({ stop: 993, restart: 1122 }); + expect(parkedWindow('calm')).toEqual({ stop: 995, restart: 1115 }); + expect(parkedWindow('windy')).toEqual({ stop: 989, restart: 1132 }); + }); +}); + +describe('operatingAt', () => { + const { stop, restart } = parkedWindow('typical') ?? { stop: 0, restart: 0 }; + + it('runs at full power at noon', () => { + const noon = operatingAt(720, 'typical'); + expect(noon.state).toBe('full'); + expect(noon.producing).toBe(true); + expect(noon.braked).toBe(false); + }); + + it('feathers the blades while stopping', () => { + const halfway = operatingAt(stop + 10, 'typical'); + const runningPitch = runningPitchDeg(dayWind(stop, 'typical')); + expect(halfway.state).toBe('stopping'); + expect(halfway.producing).toBe(false); + expect(halfway.braked).toBe(false); + expect(halfway.pitchDeg).toBeGreaterThan(runningPitch); + expect(halfway.pitchDeg).toBeLessThan(90); + expect(operatingAt(stop + 19, 'typical').braked).toBe(true); + }); + + it('parks the rotor feathered and braked in the storm core', () => { + expect(operatingAt(1050, 'typical')).toEqual({ + state: 'parked', + rpm: 0, + pitchDeg: 90, + braked: true, + producing: false, + }); + }); + + it('releases the brake while starting and runs again afterwards', () => { + const starting = operatingAt(restart + 10, 'typical'); + expect(starting.state).toBe('starting'); + expect(starting.braked).toBe(false); + expect(starting.producing).toBe(false); + const running = operatingAt(restart + 20, 'typical'); + expect(running.producing).toBe(true); + expect(running.braked).toBe(false); + }); +}); + +describe('operatingAtWind', () => { + it('parks at cut-out and ramps down just below it', () => { + expect(operatingAtWind(25).state).toBe('parked'); + expect(operatingAtWind(23).state).toBe('rampDown'); + }); +}); + +describe('advanceAzimuth', () => { + it('turns one full revolution in 6 s at 10 rpm', () => { + const azimuth = advanceAzimuth(0, 10, 6); + expect(Math.min(azimuth, FULL_TURN_RAD - azimuth)).toBeCloseTo(0, 9); + }); + + it('keeps the angle within one turn', () => { + [-10, -1, -FULL_TURN_RAD].forEach((start) => { + const azimuth = advanceAzimuth(start, 0, 1); + expect(azimuth).toBeGreaterThanOrEqual(0); + expect(azimuth).toBeLessThan(FULL_TURN_RAD); + }); + expect(advanceAzimuth(-1, 0, 1)).toBeCloseTo(FULL_TURN_RAD - 1, 9); + }); +}); diff --git a/explainers/wind-farm/src/model/curves.test.ts b/explainers/wind-farm/src/model/curves.test.ts new file mode 100644 index 00000000..522af702 --- /dev/null +++ b/explainers/wind-farm/src/model/curves.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; +import { PITCH_KNOTS, POWER_CURVE, THRUST_CURVE, interpolateKnots } from './curves'; +import type { Knot } from './curves'; + +const LINE: readonly Knot[] = [ + [0, 10], + [10, 30], + [20, 0], +]; + +describe('interpolateKnots', () => { + it('holds the first and last values beyond the ends', () => { + expect(interpolateKnots(LINE, -5)).toBe(10); + expect(interpolateKnots(LINE, 0)).toBe(10); + expect(interpolateKnots(LINE, 20)).toBe(0); + expect(interpolateKnots(LINE, 99)).toBe(0); + }); + + it('draws a straight line between neighbouring knots', () => { + expect(interpolateKnots(LINE, 5)).toBeCloseTo(20, 9); + expect(interpolateKnots(LINE, 10)).toBeCloseTo(30, 9); + expect(interpolateKnots(LINE, 15)).toBeCloseTo(15, 9); + }); + + it('reads 2127 kW from the power curve at 7.5 m/s', () => { + expect(interpolateKnots(POWER_CURVE, 7.5)).toBeCloseTo(2127, 9); + }); +}); + +describe('curves', () => { + it('lists every curve with rising wind speeds', () => { + [POWER_CURVE, THRUST_CURVE, PITCH_KNOTS].forEach((curve) => { + curve.slice(1).forEach(([x], index) => { + expect(x).toBeGreaterThan(curve[index][0]); + }); + }); + }); + + it('holds 4200 kW between 12 and 20 m/s', () => { + expect(interpolateKnots(POWER_CURVE, 12)).toBe(4200); + expect(interpolateKnots(POWER_CURVE, 16)).toBe(4200); + expect(interpolateKnots(POWER_CURVE, 20)).toBe(4200); + }); +}); diff --git a/explainers/wind-farm/src/model/day.test.ts b/explainers/wind-farm/src/model/day.test.ts new file mode 100644 index 00000000..85302c04 --- /dev/null +++ b/explainers/wind-farm/src/model/day.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest'; +import { PHASE_IDS, SITE_WIND_IDS } from '../ids'; +import { + CYCLE_MINUTES, + PHASE_RANGES, + WIND_PRESETS, + clockOf, + dayWind, + phaseOf, + rate, + windFromDeg, + wrapMinute, +} from './day'; + +describe('day phases', () => { + it('covers the whole day with ranges that follow each other', () => { + expect(CYCLE_MINUTES).toBe(1440); + expect(PHASE_RANGES[PHASE_IDS[0]][0]).toBe(0); + expect(PHASE_RANGES[PHASE_IDS[PHASE_IDS.length - 1]][1]).toBe(CYCLE_MINUTES); + PHASE_IDS.slice(1).forEach((id, index) => { + expect(PHASE_RANGES[id][0]).toBe(PHASE_RANGES[PHASE_IDS[index]][1]); + }); + }); + + it('switches phase exactly at each boundary', () => { + expect(phaseOf(0)).toBe('night'); + expect(phaseOf(359)).toBe('night'); + expect(phaseOf(360)).toBe('morning'); + expect(phaseOf(1147)).toBe('storm'); + expect(phaseOf(1148)).toBe('evening'); + expect(phaseOf(1440)).toBe('night'); + }); + + it('wraps minutes into one day', () => { + expect(wrapMinute(1500)).toBe(60); + expect(wrapMinute(-60)).toBe(1380); + }); +}); + +describe('clockOf', () => { + it('prints the minute as a 24 hour clock', () => { + expect(clockOf(0)).toBe('00:00'); + expect(clockOf(1035)).toBe('17:15'); + expect(clockOf(1440)).toBe('00:00'); + expect(clockOf(-5)).toBe('23:55'); + }); +}); + +describe('dayWind', () => { + it('scales the base wind by the site', () => { + expect(dayWind(600, 'typical')).toBeCloseTo(9, 9); + expect(dayWind(600, 'calm')).toBeCloseTo(7.2, 9); + expect(dayWind(600, 'windy')).toBeCloseTo(10.35, 9); + expect(dayWind(960, 'calm')).toBeCloseTo(14.4, 9); + }); + + it('blows 26 m/s at every site in the storm core', () => { + SITE_WIND_IDS.forEach((site) => { + expect(dayWind(1050, site)).toBeCloseTo(26, 9); + }); + }); + + it('joins the end of the day to its start', () => { + SITE_WIND_IDS.forEach((site) => { + expect(dayWind(CYCLE_MINUTES, site)).toBeCloseTo(dayWind(0, site), 9); + }); + }); +}); + +describe('windFromDeg', () => { + it('veers 6 degrees either side of west through the day', () => { + expect(windFromDeg(0)).toBeCloseTo(270, 9); + expect(windFromDeg(360)).toBeCloseTo(276, 9); + expect(windFromDeg(1080)).toBeCloseTo(264, 9); + }); +}); + +describe('rate', () => { + it('turns the speed multiplier into clock minutes per second', () => { + expect(rate(12)).toBe(12); + }); +}); + +describe('WIND_PRESETS', () => { + it('pins the wind speeds of the five presets', () => { + expect(WIND_PRESETS).toEqual({ cutIn: 3, peak: 8, rated: 12, rampDown: 23, cutOut: 25 }); + }); +}); diff --git a/explainers/wind-farm/src/model/layout.test.ts b/explainers/wind-farm/src/model/layout.test.ts new file mode 100644 index 00000000..bed6470b --- /dev/null +++ b/explainers/wind-farm/src/model/layout.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest'; +import { + COLUMN_COUNT, + HERO_SITE, + ROW_COUNT, + SUBSTATION, + collectorRoutes, + farmLayout, + terrainHeight, +} from './layout'; + +const MAX_TERRAIN_M = 27; + +describe('farmLayout', () => { + it('places 27 turbines in three rows along the wind', () => { + const sites = farmLayout(7); + expect(sites).toHaveLength(27); + expect(ROW_COUNT * COLUMN_COUNT).toBe(27); + sites.forEach((site) => { + expect(site.x).toBe([-1050, 0, 1050][site.row]); + }); + }); + + it('staggers the middle row against the outer rows', () => { + farmLayout(7).forEach((site) => { + const stagger = site.row === 1 ? 150 : -150; + expect(site.z).toBe((site.column - 4) * 600 + stagger); + }); + }); + + it('puts the hero turbine in the middle of the front row', () => { + expect(HERO_SITE).toBe(4); + expect(farmLayout(7)[HERO_SITE]).toEqual({ row: 0, column: 4, x: -1050, z: -150 }); + }); + + it('moves the outer rows with the spacing', () => { + const outerX = (spacing: 5 | 9) => + farmLayout(spacing) + .filter((site) => site.row !== 1) + .map((site) => site.x); + expect(new Set(outerX(5))).toEqual(new Set([-750, 750])); + expect(new Set(outerX(9))).toEqual(new Set([-1350, 1350])); + }); +}); + +describe('collectorRoutes', () => { + it('runs one cable per row along the turbines to the substation', () => { + const routes = collectorRoutes(7); + expect(routes).toHaveLength(3); + routes.forEach((route) => { + expect(route).toHaveLength(10); + const turbines = route.slice(0, -1); + turbines.slice(1).forEach(([, z], index) => { + expect(z).toBeGreaterThan(turbines[index][1]); + }); + expect(route[route.length - 1]).toEqual([SUBSTATION.x, SUBSTATION.z]); + expect(route[route.length - 1]).toEqual([2200, 3000]); + }); + }); +}); + +describe('terrainHeight', () => { + it('keeps the hills within 27 m of the base level', () => { + for (let x = -4000; x <= 6000; x += 250) { + for (let z = -5000; z <= 5000; z += 250) { + expect(Math.abs(terrainHeight(x, z))).toBeLessThanOrEqual(MAX_TERRAIN_M); + } + } + }); +}); diff --git a/explainers/wind-farm/src/theme.test.ts b/explainers/wind-farm/src/theme.test.ts new file mode 100644 index 00000000..442d2cf0 --- /dev/null +++ b/explainers/wind-farm/src/theme.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import { THEME as CORE_THEME } from '@core/theme'; +import { OPERATING_STATE_IDS, PHASE_IDS } from './ids'; +import { PHASE_TONES, STATE_TONES, THEME } from './theme'; + +const HEX_COLOUR = /^#[0-9a-f]{6}$/i; + +describe('theme', () => { + it('keeps the core tokens', () => { + expect(THEME).toMatchObject(CORE_THEME); + }); + + it('writes every colour as hex', () => { + Object.values(THEME).forEach((colour) => { + expect(colour).toMatch(HEX_COLOUR); + }); + }); + + it('gives each phase of the day its own tone', () => { + expect(Object.keys(PHASE_TONES)).toEqual([...PHASE_IDS]); + expect(new Set(Object.values(PHASE_TONES)).size).toBe(PHASE_IDS.length); + Object.values(PHASE_TONES).forEach((tone) => { + expect(tone).toMatch(HEX_COLOUR); + }); + }); + + it('gives each operating state a tone', () => { + expect(Object.keys(STATE_TONES)).toEqual([...OPERATING_STATE_IDS]); + Object.values(STATE_TONES).forEach((tone) => { + expect(tone).toMatch(HEX_COLOUR); + }); + }); +}); From 9a8a26594b94128b863c720ea60a300445ecea7a Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:05:18 +0200 Subject: [PATCH 003/132] feat(wind-farm): add the assembly contract and placeholder --- explainers/wind-farm/src/scene/assembly.ts | 25 ++ .../wind-farm/src/scene/placeholder/build.ts | 187 +++++++++++ .../wind-farm/src/scene/placeholder/farm.ts | 255 +++++++++++++++ .../src/scene/placeholder/finishes.ts | 66 ++++ .../src/scene/placeholder/interior.ts | 130 ++++++++ .../src/scene/placeholder/overlays.ts | 180 +++++++++++ .../src/scene/placeholder/regions.ts | 93 ++++++ .../src/scene/placeholder/turbine.ts | 290 ++++++++++++++++++ .../src/scene/placeholderAssembly.dom.test.ts | 172 +++++++++++ .../src/scene/placeholderAssembly.ts | 125 ++++++++ 10 files changed, 1523 insertions(+) create mode 100644 explainers/wind-farm/src/scene/assembly.ts create mode 100644 explainers/wind-farm/src/scene/placeholder/build.ts create mode 100644 explainers/wind-farm/src/scene/placeholder/farm.ts create mode 100644 explainers/wind-farm/src/scene/placeholder/finishes.ts create mode 100644 explainers/wind-farm/src/scene/placeholder/interior.ts create mode 100644 explainers/wind-farm/src/scene/placeholder/overlays.ts create mode 100644 explainers/wind-farm/src/scene/placeholder/regions.ts create mode 100644 explainers/wind-farm/src/scene/placeholder/turbine.ts create mode 100644 explainers/wind-farm/src/scene/placeholderAssembly.dom.test.ts create mode 100644 explainers/wind-farm/src/scene/placeholderAssembly.ts diff --git a/explainers/wind-farm/src/scene/assembly.ts b/explainers/wind-farm/src/scene/assembly.ts new file mode 100644 index 00000000..f75204c9 --- /dev/null +++ b/explainers/wind-farm/src/scene/assembly.ts @@ -0,0 +1,25 @@ +import type { Box3, Group, Object3D } from 'three'; +import type { MaterialLibrary } from '@core/scene/materials'; +import type { SceneTextures } from '@core/scene/textures'; +import type { AnchorId, AssemblyState, PartId, RegionId } from '../ids'; +import { createPlaceholderAssembly } from './placeholderAssembly'; + +export interface AssemblyResources { + materials: MaterialLibrary; + textures: SceneTextures; +} + +export interface Assembly { + readonly root: Group; + setState(state: AssemblyState): void; + update(deltaSeconds: number, cameraDistance: number): boolean; + labelAnchors(): ReadonlyMap; + anchor(id: AnchorId): Object3D; + region(id: RegionId): Box3; + warmUp?(compile: (object: Object3D) => void): void; + dispose(): void; +} + +export function createAssembly(resources: AssemblyResources, state: AssemblyState): Assembly { + return createPlaceholderAssembly(resources, state); +} diff --git a/explainers/wind-farm/src/scene/placeholder/build.ts b/explainers/wind-farm/src/scene/placeholder/build.ts new file mode 100644 index 00000000..ac003836 --- /dev/null +++ b/explainers/wind-farm/src/scene/placeholder/build.ts @@ -0,0 +1,187 @@ +import { + BoxGeometry, + ConeGeometry, + CylinderGeometry, + Group, + InstancedMesh, + Matrix4, + Mesh, + Quaternion, + Vector3, +} from 'three'; +import type { BufferGeometry, Object3D } from 'three'; +import { mergeGeometries } from 'three/addons/utils/BufferGeometryUtils.js'; +import type { MaterialLibrary } from '@core/scene/materials'; +import { anchorAt } from '@core/scene/parts'; +import type { ResourceTracker } from '@core/scene/resources'; +import type { AnchorId, AssemblyState, PartId, Point } from '../../ids'; +import { MAX_PLUME_D, ROTOR_DIAMETER_M, ROTOR_RADIUS_M, WAKE_DECAY } from '../../model'; +import { terrainHeight } from '../../model'; +import type { GroundPoint } from '../../model'; +import { PART_FINISHES } from './finishes'; + +const ROUND_SEGMENTS = 24; +const GROUND_STEP_M = 100; +const ARROW_HEAD_SHARE = 0.3; +const ARROW_HEAD_WIDTH = 3; +const X_AXIS = new Vector3(1, 0, 0); +const QUARTER_TURN = Math.PI / 2; +const DEGREES_TO_RADIANS = Math.PI / 180; +const FACING_WEST_DEG = 270; + +export const PLUME_REFERENCE_M = MAX_PLUME_D * ROTOR_DIAMETER_M; + +export interface PlaceholderContext { + materials: MaterialLibrary; + tracker: ResourceTracker; +} + +export interface PlaceholderScene { + readonly root: Group; + readonly labels: ReadonlyMap; + readonly anchors: Partial>; + setState(state: AssemblyState): void; + setAzimuth(azimuth: number): void; +} + +export type Labels = Map; + +export function label(labels: Labels, part: PartId, parent: Object3D, point: Point): void { + labels.set(part, anchorAt(parent, ...point)); +} + +export function bearingTurn(bearingDeg: number): number { + return (FACING_WEST_DEG - bearingDeg) * DEGREES_TO_RADIANS; +} + +export function degrees(value: number): number { + return value * DEGREES_TO_RADIANS; +} + +export function namedGroup(name: string, parent?: Object3D): Group { + const group = new Group(); + group.name = name; + parent?.add(group); + return group; +} + +export function partMesh( + context: PlaceholderContext, + part: PartId, + geometry: BufferGeometry, + parent: Object3D, +): Mesh { + const mesh = new Mesh( + context.tracker.track(geometry), + context.materials.get(part, PART_FINISHES[part]), + ); + mesh.name = part; + parent.add(mesh); + return mesh; +} + +export function instancedPart( + context: PlaceholderContext, + part: PartId, + geometry: BufferGeometry, + count: number, + parent: Object3D, +): InstancedMesh { + const material = context.materials.get(part, PART_FINISHES[part]); + const mesh = context.tracker.track( + new InstancedMesh(context.tracker.track(geometry), material, count), + ); + mesh.name = part; + mesh.frustumCulled = false; + parent.add(mesh); + return mesh; +} + +export function merged(parts: readonly BufferGeometry[]): BufferGeometry { + const geometry = mergeGeometries([...parts]); + parts.forEach((part) => part.dispose()); + if (!geometry) throw new Error('Could not merge the placeholder geometries'); + return geometry; +} + +export function boxAt(size: Point, centre: Point): BufferGeometry { + return new BoxGeometry(...size).translate(...centre); +} + +export function boxBetween(min: Point, max: Point): BufferGeometry { + const size: Point = [max[0] - min[0], max[1] - min[1], max[2] - min[2]]; + const centre: Point = [(min[0] + max[0]) / 2, (min[1] + max[1]) / 2, (min[2] + max[2]) / 2]; + return boxAt(size, centre); +} + +export function cylinderAlongX( + minX: number, + maxX: number, + radius: number, + y: number, +): BufferGeometry { + return new CylinderGeometry(radius, radius, maxX - minX, ROUND_SEGMENTS) + .rotateZ(QUARTER_TURN) + .translate((minX + maxX) / 2, y, 0); +} + +export function upright(bottomRadius: number, topRadius: number, height: number): BufferGeometry { + return new CylinderGeometry(topRadius, bottomRadius, height, ROUND_SEGMENTS).translate( + 0, + height / 2, + 0, + ); +} + +function segmentBox(from: Vector3, to: Vector3, width: number, thickness: number): BufferGeometry { + const direction = to.clone().sub(from); + const length = direction.length(); + const rotation = new Quaternion().setFromUnitVectors(X_AXIS, direction.normalize()); + const placement = new Matrix4().compose( + from.clone().add(to).multiplyScalar(0.5), + rotation, + new Vector3(1, 1, 1), + ); + return new BoxGeometry(length, thickness, width).applyMatrix4(placement); +} + +export function ribbon( + points: readonly Vector3[], + width: number, + thickness = width, +): BufferGeometry { + return merged( + points.slice(1).map((point, index) => segmentBox(points[index], point, width, thickness)), + ); +} + +export function groundPath(route: readonly GroundPoint[], lift: number): Vector3[] { + return route.flatMap(([x, z], index) => { + if (index === 0) return [new Vector3(x, terrainHeight(x, z) + lift, z)]; + const [fromX, fromZ] = route[index - 1]; + const steps = Math.max(1, Math.ceil(Math.hypot(x - fromX, z - fromZ) / GROUND_STEP_M)); + return Array.from({ length: steps }, (_, step) => { + const share = (step + 1) / steps; + const pointX = fromX + (x - fromX) * share; + const pointZ = fromZ + (z - fromZ) * share; + return new Vector3(pointX, terrainHeight(pointX, pointZ) + lift, pointZ); + }); + }); +} + +export function arrowAlongX(length: number, width: number, origin: Point): BufferGeometry { + const headLength = length * ARROW_HEAD_SHARE; + const shaftLength = length - headLength; + const shaft = new BoxGeometry(shaftLength, width, width).translate(shaftLength / 2, 0, 0); + const head = new ConeGeometry(width * ARROW_HEAD_WIDTH, headLength, ROUND_SEGMENTS) + .rotateZ(-QUARTER_TURN) + .translate(shaftLength + headLength / 2, 0, 0); + return merged([shaft, head]).translate(...origin); +} + +export function plumeGeometry(): BufferGeometry { + const farRadius = ROTOR_RADIUS_M + WAKE_DECAY * PLUME_REFERENCE_M; + return new CylinderGeometry(farRadius, ROTOR_RADIUS_M, PLUME_REFERENCE_M, ROUND_SEGMENTS, 1, true) + .rotateZ(-QUARTER_TURN) + .translate(PLUME_REFERENCE_M / 2, 0, 0); +} diff --git a/explainers/wind-farm/src/scene/placeholder/farm.ts b/explainers/wind-farm/src/scene/placeholder/farm.ts new file mode 100644 index 00000000..e4d08514 --- /dev/null +++ b/explainers/wind-farm/src/scene/placeholder/farm.ts @@ -0,0 +1,255 @@ +import { Matrix4, PlaneGeometry, Quaternion, Vector3 } from 'three'; +import type { BufferGeometry, Group, InstancedMesh, Object3D } from 'three'; +import { anchorAt } from '@core/scene/parts'; +import type { AnchorId, AssemblyState, FarmSite, Point, SpacingD } from '../../ids'; +import { + BLADE_LENGTH_M, + COLUMN_COUNT, + FARM_TERRAIN, + GRID_LINE_END, + HERO_SITE, + HUB_HEIGHT_M, + MAX_CHORD_M, + PYLON_SPACING_M, + ROTOR_DIAMETER_M, + SUBSTATION, + TIP_HEIGHT_M, + TURBINE_COUNT, + TURBINE_GEOMETRY, + farmLayout, + terrainHeight, +} from '../../model'; +import { + PLUME_REFERENCE_M, + bearingTurn, + boxAt, + boxBetween, + groundPath, + instancedPart, + label, + merged, + namedGroup, + partMesh, + plumeGeometry, + ribbon, +} from './build'; +import type { Labels, PlaceholderContext, PlaceholderScene } from './build'; +import { FarmOverlays } from './overlays'; +import { SUBSTATION_HEIGHT_M } from './regions'; + +const [HUB_X, HUB_Y] = TURBINE_GEOMETRY.hub; +const NACELLE = TURBINE_GEOMETRY.nacelle; +const TERRAIN_SEGMENTS = 100; +const STAND_IN_TOWER_M = 3.5; +const STAND_IN_BLADE_M = 2; +const BLADE_COUNT = 3; +const GRID_LINE_HEIGHT_M = 30; +const GRID_LINE_WIDTH_M = 1.5; +const PYLON_WIDTH_M = 4; +const FARM_LAND_LABEL: readonly [x: number, z: number] = [ + FARM_TERRAIN.minX + 1000, + FARM_TERRAIN.maxZ - 1000, +]; +const PLUME_LABEL_REACH_M = 3 * ROTOR_DIAMETER_M; +const LABELLED_TURBINE = COLUMN_COUNT + HERO_SITE; +const UNIT_SCALE = new Vector3(1, 1, 1); +const X_AXIS = new Vector3(1, 0, 0); +const Y_AXIS = new Vector3(0, 1, 0); + +interface Fleet { + towers: InstancedMesh; + rotors: InstancedMesh; + plumes: InstancedMesh; + plumeGroup: Group; +} + +const placement = new Matrix4(); +const local = new Matrix4(); +const turn = new Quaternion(); + +function terrainGeometry(): BufferGeometry { + const { minX, maxX, minZ, maxZ } = FARM_TERRAIN; + const geometry = new PlaneGeometry(maxX - minX, maxZ - minZ, TERRAIN_SEGMENTS, TERRAIN_SEGMENTS) + .rotateX(-Math.PI / 2) + .translate((minX + maxX) / 2, 0, (minZ + maxZ) / 2); + const position = geometry.getAttribute('position'); + for (let index = 0; index < position.count; index += 1) { + position.setY(index, terrainHeight(position.getX(index), position.getZ(index))); + } + geometry.computeVertexNormals(); + return geometry; +} + +function towerGeometry(): BufferGeometry { + const { towerTopY } = TURBINE_GEOMETRY; + return merged([ + boxAt([STAND_IN_TOWER_M, towerTopY, STAND_IN_TOWER_M], [0, towerTopY / 2, 0]), + boxBetween( + [NACELLE.minX, NACELLE.minY, -NACELLE.halfWidth], + [NACELLE.maxX, NACELLE.maxY, NACELLE.halfWidth], + ), + ]); +} + +function rotorGeometry(): BufferGeometry { + const root = TURBINE_GEOMETRY.bladeRootRadius; + const blades = Array.from({ length: BLADE_COUNT }, (_, index) => + boxAt( + [STAND_IN_BLADE_M, BLADE_LENGTH_M, MAX_CHORD_M], + [0, root + BLADE_LENGTH_M / 2, 0], + ).rotateX((index * 2 * Math.PI) / BLADE_COUNT), + ); + return merged(blades); +} + +function gridLineGeometry(): BufferGeometry { + const route: [number, number][] = [ + [SUBSTATION.x, SUBSTATION.z], + [GRID_LINE_END.x, GRID_LINE_END.z], + ]; + const line = ribbon(groundPath(route, GRID_LINE_HEIGHT_M), GRID_LINE_WIDTH_M); + const pylonCount = Math.floor((GRID_LINE_END.x - SUBSTATION.x) / PYLON_SPACING_M); + const half = PYLON_WIDTH_M / 2; + const pylons = Array.from({ length: pylonCount }, (_, index) => { + const x = SUBSTATION.x + (index + 1) * PYLON_SPACING_M; + const ground = terrainHeight(x, GRID_LINE_END.z); + return boxBetween( + [x - half, ground, GRID_LINE_END.z - half], + [x + half, ground + GRID_LINE_HEIGHT_M, GRID_LINE_END.z + half], + ); + }); + return merged([line, ...pylons]); +} + +function substationPoint(lift: number): Point { + return [SUBSTATION.x, terrainHeight(SUBSTATION.x, SUBSTATION.z) + lift, SUBSTATION.z]; +} + +function buildStatic(context: PlaceholderContext, root: Group, labels: Labels): Group { + const [landX, landZ] = FARM_LAND_LABEL; + partMesh(context, 'farmLand', terrainGeometry(), root); + const [x, ground, z] = substationPoint(0); + const footprint = boxAt( + [SUBSTATION.width, SUBSTATION_HEIGHT_M, SUBSTATION.depth], + [x, ground + SUBSTATION_HEIGHT_M / 2, z], + ); + partMesh(context, 'substation', footprint, root); + const gridLine = namedGroup('gridLine', root); + partMesh(context, 'gridLine', gridLineGeometry(), gridLine); + label(labels, 'farmLand', root, [landX, terrainHeight(landX, landZ), landZ]); + label(labels, 'substation', root, substationPoint(SUBSTATION_HEIGHT_M)); + const middleX = (SUBSTATION.x + GRID_LINE_END.x) / 2; + label(labels, 'gridLine', gridLine, [ + middleX, + terrainHeight(middleX, GRID_LINE_END.z) + GRID_LINE_HEIGHT_M, + GRID_LINE_END.z, + ]); + return gridLine; +} + +function buildFleet(context: PlaceholderContext, root: Group): Fleet { + const towers = instancedPart(context, 'farmTurbines', towerGeometry(), TURBINE_COUNT, root); + const rotors = instancedPart(context, 'farmTurbines', rotorGeometry(), TURBINE_COUNT, root); + const plumeGroup = namedGroup('wakePlumes', root); + const plumes = instancedPart(context, 'wakePlumes', plumeGeometry(), TURBINE_COUNT, plumeGroup); + return { towers, rotors, plumes, plumeGroup }; +} + +function sitePlacement(site: FarmSite, yaw: number): Matrix4 { + turn.setFromAxisAngle(Y_AXIS, yaw); + return placement.compose( + new Vector3(site.x, terrainHeight(site.x, site.z), site.z), + turn, + UNIT_SCALE, + ); +} + +function writeTowers( + fleet: Fleet, + sites: readonly FarmSite[], + yaw: number, + plumeScale: number, +): void { + sites.forEach((site, index) => { + const base = sitePlacement(site, yaw); + fleet.towers.setMatrixAt(index, base); + local.compose(new Vector3(HUB_X, HUB_Y, 0), turn.identity(), new Vector3(plumeScale, 1, 1)); + fleet.plumes.setMatrixAt(index, base.multiply(local)); + }); + fleet.towers.instanceMatrix.needsUpdate = true; + fleet.plumes.instanceMatrix.needsUpdate = true; +} + +function writeRotors(fleet: Fleet, sites: readonly FarmSite[], yaw: number, azimuth: number): void { + sites.forEach((site, index) => { + const base = sitePlacement(site, yaw); + turn.setFromAxisAngle(X_AXIS, azimuth); + local.compose(new Vector3(HUB_X, HUB_Y, 0), turn, UNIT_SCALE); + fleet.rotors.setMatrixAt(index, base.multiply(local)); + }); + fleet.rotors.instanceMatrix.needsUpdate = true; +} + +function placeLayoutAnchors( + labels: Labels, + anchors: Record<'farmCentre' | 'heroSite', Object3D>, + spacing: SpacingD, +) { + const sites = farmLayout(spacing); + const hero = sites[HERO_SITE]; + const heroGround = terrainHeight(hero.x, hero.z); + const centreX = sites.reduce((sum, site) => sum + site.x, 0) / sites.length; + const centreZ = sites.reduce((sum, site) => sum + site.z, 0) / sites.length; + const labelled = sites[LABELLED_TURBINE]; + anchors.heroSite.position.set(hero.x, heroGround + HUB_HEIGHT_M, hero.z); + anchors.farmCentre.position.set(centreX, terrainHeight(centreX, centreZ), centreZ); + labels + .get('farmTurbines') + ?.position.set(labelled.x, terrainHeight(labelled.x, labelled.z) + TIP_HEIGHT_M, labelled.z); + labels + .get('wakePlumes') + ?.position.set(hero.x + PLUME_LABEL_REACH_M, heroGround + HUB_HEIGHT_M, hero.z); +} + +export function buildFarmScene(context: PlaceholderContext): PlaceholderScene { + const root = namedGroup('farmScene'); + const labels: Labels = new Map(); + const gridLine = buildStatic(context, root, labels); + const fleet = buildFleet(context, root); + const overlays = new FarmOverlays(context, root, labels); + label(labels, 'farmTurbines', root, [0, 0, 0]); + label(labels, 'wakePlumes', fleet.plumeGroup, [0, 0, 0]); + const layoutAnchors = { farmCentre: anchorAt(root, 0, 0, 0), heroSite: anchorAt(root, 0, 0, 0) }; + const anchors: Partial> = { + ...layoutAnchors, + substation: anchorAt(root, ...substationPoint(SUBSTATION_HEIGHT_M)), + }; + let current: AssemblyState | undefined; + let azimuth = 0; + return { + root, + labels, + anchors, + setState: (state) => { + const { view, farm, rotor } = state; + if (farm.spacing !== current?.farm.spacing) + placeLayoutAnchors(labels, layoutAnchors, farm.spacing); + current = state; + overlays.layOut(farm.spacing); + overlays.group('collectorCables').visible = view.cables; + overlays.group('windArrows').visible = view.streamlines; + overlays.group('shearProfile').visible = view.streamlines; + gridLine.visible = view.cables; + fleet.plumeGroup.visible = view.wakes && farm.plumeLengthD > 0; + const plumeScale = + (Math.max(farm.plumeLengthD, Number.EPSILON) * ROTOR_DIAMETER_M) / PLUME_REFERENCE_M; + writeTowers(fleet, farm.sites, bearingTurn(rotor.yawDeg), plumeScale); + writeRotors(fleet, farm.sites, bearingTurn(rotor.yawDeg), azimuth); + }, + setAzimuth: (next) => { + azimuth = next; + if (!current || !root.visible) return; + writeRotors(fleet, current.farm.sites, bearingTurn(current.rotor.yawDeg), azimuth); + }, + }; +} diff --git a/explainers/wind-farm/src/scene/placeholder/finishes.ts b/explainers/wind-farm/src/scene/placeholder/finishes.ts new file mode 100644 index 00000000..fb904eb5 --- /dev/null +++ b/explainers/wind-farm/src/scene/placeholder/finishes.ts @@ -0,0 +1,66 @@ +import { DoubleSide } from 'three'; +import type { MaterialFinish } from '@core/scene/materials'; +import type { PartId } from '../../ids'; +import { THEME } from '../../theme'; + +const MATTE = 0.85; +const SATIN = 0.5; +const METAL = 0.6; +const GLOW = 0.6; +const VEIL_OPACITY = 0.3; +const RING_OPACITY = 0.45; + +function matte(color: string): MaterialFinish { + return { color, roughness: MATTE, metalness: 0 }; +} + +function satin(color: string): MaterialFinish { + return { color, roughness: SATIN, metalness: 0.1 }; +} + +function metal(color: string): MaterialFinish { + return { color, roughness: SATIN, metalness: METAL }; +} + +function glowing(color: string): MaterialFinish { + return { color, emissive: color, emissiveIntensity: GLOW, roughness: MATTE }; +} + +function veil(color: string, opacity: number): MaterialFinish { + return { color, transparent: true, opacity, depthWrite: false, side: DoubleSide }; +} + +export const PART_FINISHES: Readonly> = { + land: matte(THEME.grass), + foundation: matte(THEME.concrete), + tower: satin(THEME.turbineWhite), + transformer: satin(THEME.substation), + towerCable: glowing(THEME.cable), + nacelle: satin(THEME.turbineWhite), + cooler: satin(THEME.cooler), + bedplate: metal(THEME.castIron), + hub: satin(THEME.turbineWhite), + blades: satin(THEME.turbineGrey), + pitchCylinders: metal(THEME.hydraulic), + mainBearing: metal(THEME.steel), + mainShaft: metal(THEME.steel), + gearbox: metal(THEME.gearOil), + brakeDisc: metal(THEME.brakeGlow), + generator: metal(THEME.copper), + converter: satin(THEME.converterCabinet), + yawDrives: metal(THEME.castIron), + sweptArea: veil(THEME.wind, RING_OPACITY), + streamlinesGroup: glowing(THEME.wind), + heroWake: veil(THEME.wake, VEIL_OPACITY), + farmLand: matte(THEME.grassDark), + farmTurbines: satin(THEME.turbineWhite), + accessRoads: matte(THEME.gravel), + spacingMarker: glowing(THEME.spacing), + prevailingWind: glowing(THEME.wind), + windArrows: glowing(THEME.wind), + shearProfile: glowing(THEME.wind), + wakePlumes: veil(THEME.wake, VEIL_OPACITY), + collectorCables: glowing(THEME.cable), + substation: satin(THEME.substation), + gridLine: metal(THEME.gridLine), +}; diff --git a/explainers/wind-farm/src/scene/placeholder/interior.ts b/explainers/wind-farm/src/scene/placeholder/interior.ts new file mode 100644 index 00000000..936c1f8f --- /dev/null +++ b/explainers/wind-farm/src/scene/placeholder/interior.ts @@ -0,0 +1,130 @@ +import type { Group } from 'three'; +import { TURBINE_GEOMETRY } from '../../model'; +import { boxBetween, cylinderAlongX, label, merged, namedGroup, partMesh, upright } from './build'; +import type { Labels, PlaceholderContext } from './build'; + +const GEOMETRY = TURBINE_GEOMETRY; +const [HUB_X, HUB_Y] = GEOMETRY.hub; +const SHAFT_Y = GEOMETRY.shaftY; +const BEDPLATE_THICKNESS_M = 0.4; +const BEDPLATE_HALF_WIDTH_M = 1.6; +const BEARING_HALF_LENGTH_M = 0.4; +const BRAKE_HALF_THICKNESS_M = 0.1; +const CONVERTER_DEPTH_M = 0.8; +const CONVERTER_HEIGHT_M = 2.4; +const PITCH_COUNT = 3; +const PITCH_RADIUS_M = 0.15; +const PITCH_LENGTH_M = 1; +const PITCH_OFFSET_M = 0.3; +const YAW_DRIVE_COUNT = 8; +const YAW_DRIVE_RADIUS_M = 0.25; +const YAW_DRIVE_HEIGHT_M = 0.8; + +export interface Interior { + group: Group; + spinner: Group; +} + +function buildShaftLine(context: PlaceholderContext, group: Group, labels: Labels): void { + const { mainBearing, mainShaft, gearbox, brakeDisc, generator } = GEOMETRY; + const bearing = cylinderAlongX( + mainBearing.x - BEARING_HALF_LENGTH_M, + mainBearing.x + BEARING_HALF_LENGTH_M, + mainBearing.radius, + SHAFT_Y, + ); + const brake = cylinderAlongX( + brakeDisc.x - BRAKE_HALF_THICKNESS_M, + brakeDisc.x + BRAKE_HALF_THICKNESS_M, + brakeDisc.radius, + SHAFT_Y, + ); + partMesh(context, 'mainBearing', bearing, group); + partMesh( + context, + 'mainShaft', + cylinderAlongX(mainShaft.minX, mainShaft.maxX, mainShaft.radius, SHAFT_Y), + group, + ); + partMesh( + context, + 'gearbox', + cylinderAlongX(gearbox.minX, gearbox.maxX, gearbox.radius, SHAFT_Y), + group, + ); + partMesh(context, 'brakeDisc', brake, group); + partMesh( + context, + 'generator', + cylinderAlongX(generator.minX, generator.maxX, generator.radius, SHAFT_Y), + group, + ); + label(labels, 'mainBearing', group, [mainBearing.x, SHAFT_Y + mainBearing.radius, 0]); + label(labels, 'mainShaft', group, [ + (mainShaft.minX + mainShaft.maxX) / 2, + SHAFT_Y + mainShaft.radius, + 0, + ]); + label(labels, 'gearbox', group, [(gearbox.minX + gearbox.maxX) / 2, SHAFT_Y + gearbox.radius, 0]); + label(labels, 'brakeDisc', group, [brakeDisc.x, SHAFT_Y + brakeDisc.radius, 0]); + label(labels, 'generator', group, [ + (generator.minX + generator.maxX) / 2, + SHAFT_Y + generator.radius, + 0, + ]); +} + +function buildFrame(context: PlaceholderContext, group: Group, labels: Labels): void { + const { bedplate, converter, yawDrives } = GEOMETRY; + const bedplateBox = boxBetween( + [bedplate.minX, bedplate.y - BEDPLATE_THICKNESS_M / 2, -BEDPLATE_HALF_WIDTH_M], + [bedplate.maxX, bedplate.y + BEDPLATE_THICKNESS_M / 2, BEDPLATE_HALF_WIDTH_M], + ); + const converterBox = boxBetween( + [converter.minX, bedplate.y, converter.wallZ], + [converter.maxX, bedplate.y + CONVERTER_HEIGHT_M, converter.wallZ + CONVERTER_DEPTH_M], + ); + const drives = Array.from({ length: YAW_DRIVE_COUNT }, (_, index) => { + const angle = (index * 2 * Math.PI) / YAW_DRIVE_COUNT; + return upright(YAW_DRIVE_RADIUS_M, YAW_DRIVE_RADIUS_M, YAW_DRIVE_HEIGHT_M).translate( + yawDrives.radius * Math.cos(angle), + yawDrives.y, + yawDrives.radius * Math.sin(angle), + ); + }); + partMesh(context, 'bedplate', bedplateBox, group); + partMesh(context, 'converter', converterBox, group); + partMesh(context, 'yawDrives', merged(drives), group); + label(labels, 'bedplate', group, [ + (bedplate.minX + bedplate.maxX) / 2, + bedplate.y, + BEDPLATE_HALF_WIDTH_M, + ]); + label(labels, 'converter', group, [ + (converter.minX + converter.maxX) / 2, + bedplate.y + CONVERTER_HEIGHT_M, + converter.wallZ, + ]); + label(labels, 'yawDrives', group, [0, yawDrives.y, yawDrives.radius]); +} + +function buildPitchCylinders(context: PlaceholderContext, group: Group, labels: Labels): Group { + const spinner = namedGroup('pitchSpinner', group); + spinner.position.set(HUB_X, HUB_Y, 0); + const cylinders = Array.from({ length: PITCH_COUNT }, (_, index) => + upright(PITCH_RADIUS_M, PITCH_RADIUS_M, PITCH_LENGTH_M) + .translate(0, PITCH_OFFSET_M, 0) + .rotateX((index * 2 * Math.PI) / PITCH_COUNT), + ); + partMesh(context, 'pitchCylinders', merged(cylinders), spinner); + label(labels, 'pitchCylinders', group, [HUB_X, HUB_Y + PITCH_OFFSET_M + PITCH_LENGTH_M, 0]); + return spinner; +} + +export function buildInterior(context: PlaceholderContext, frame: Group, labels: Labels): Interior { + const group = namedGroup('nacelleInterior', frame); + buildShaftLine(context, group, labels); + buildFrame(context, group, labels); + const spinner = buildPitchCylinders(context, group, labels); + return { group, spinner }; +} diff --git a/explainers/wind-farm/src/scene/placeholder/overlays.ts b/explainers/wind-farm/src/scene/placeholder/overlays.ts new file mode 100644 index 00000000..b2de8522 --- /dev/null +++ b/explainers/wind-farm/src/scene/placeholder/overlays.ts @@ -0,0 +1,180 @@ +import type { BufferGeometry, Group, Mesh, Object3D } from 'three'; +import type { MaterialLibrary } from '@core/scene/materials'; +import { anchorAt } from '@core/scene/parts'; +import { ResourceTracker } from '@core/scene/resources'; +import type { PartId, Point, SpacingD } from '../../ids'; +import { + COLUMN_COUNT, + HERO_SITE, + HUB_HEIGHT_M, + SHEAR_EXPONENT, + SHEAR_HEIGHTS_M, + TIP_HEIGHT_M, + collectorRoutes, + farmLayout, + shearProfileAt, + terrainHeight, + windArrowsX, +} from '../../model'; +import type { GroundPoint } from '../../model'; +import { arrowAlongX, boxBetween, groundPath, merged, namedGroup, partMesh, ribbon } from './build'; +import type { Labels, PlaceholderContext } from './build'; + +const ROAD_LIFT_M = 2; +const ROAD_WIDTH_M = 8; +const ROAD_THICKNESS_M = 0.5; +const CABLE_LIFT_M = 3; +const CABLE_WIDTH_M = 3; +const MARKER_LIFT_M = 4; +const MARKER_WIDTH_M = 5; +const WIND_ARROW_COUNT = 9; +const WIND_ARROW_LENGTH_M = 300; +const WIND_ARROW_WIDTH_M = 6; +const SHEAR_ARROW_LENGTH_M = 160; +const SHEAR_ARROW_WIDTH_M = 3; +const SHEAR_POLE_WIDTH_M = 3; +const PREVAILING_OFFSET_M = 900; +const PREVAILING_HEIGHT_M = 400; +const PREVAILING_LENGTH_M = 700; +const PREVAILING_WIDTH_M = 20; + +export type OverlayPart = Extract< + PartId, + | 'accessRoads' + | 'collectorCables' + | 'spacingMarker' + | 'windArrows' + | 'shearProfile' + | 'prevailingWind' +>; + +interface OverlayShape { + geometry: BufferGeometry; + label: Point; +} + +const lastLeg = (route: readonly GroundPoint[]): GroundPoint => { + const [fromX, fromZ] = route[route.length - 2]; + const [toX, toZ] = route[route.length - 1]; + return [(fromX + toX) / 2, (fromZ + toZ) / 2]; +}; + +const onGround = ([x, z]: GroundPoint, lift: number): Point => [x, terrainHeight(x, z) + lift, z]; + +function routesShape( + spacing: SpacingD, + lift: number, + width: number, + thickness: number, +): OverlayShape { + const routes = collectorRoutes(spacing); + const geometry = merged(routes.map((route) => ribbon(groundPath(route, lift), width, thickness))); + return { geometry, label: onGround(lastLeg(routes[1]), lift) }; +} + +function markerShape(spacing: SpacingD): OverlayShape { + const sites = farmLayout(spacing); + const hero = sites[HERO_SITE]; + const next = sites[HERO_SITE + COLUMN_COUNT]; + const path = groundPath( + [ + [hero.x, hero.z], + [next.x, hero.z], + ], + MARKER_LIFT_M, + ); + return { + geometry: ribbon(path, MARKER_WIDTH_M), + label: onGround([(hero.x + next.x) / 2, hero.z], MARKER_LIFT_M), + }; +} + +function windArrowsShape(spacing: SpacingD): OverlayShape { + const x = windArrowsX(spacing); + const zs = farmLayout(spacing).map((site) => site.z); + const minZ = Math.min(...zs); + const step = (Math.max(...zs) - minZ) / (WIND_ARROW_COUNT - 1); + const arrows = Array.from({ length: WIND_ARROW_COUNT }, (_, index) => { + const z = minZ + index * step; + return arrowAlongX(WIND_ARROW_LENGTH_M, WIND_ARROW_WIDTH_M, onGround([x, z], HUB_HEIGHT_M)); + }); + const middleZ = minZ + step * Math.floor(WIND_ARROW_COUNT / 2); + return { geometry: merged(arrows), label: onGround([x, middleZ], HUB_HEIGHT_M) }; +} + +function shearShape(spacing: SpacingD): OverlayShape { + const [x, z] = shearProfileAt(spacing); + const ground = terrainHeight(x, z); + const half = SHEAR_POLE_WIDTH_M / 2; + const pole = boxBetween( + [x - half, ground, z - half], + [x + half, ground + TIP_HEIGHT_M, z + half], + ); + const arrows = SHEAR_HEIGHTS_M.map((height) => { + const length = SHEAR_ARROW_LENGTH_M * (height / HUB_HEIGHT_M) ** SHEAR_EXPONENT; + return arrowAlongX(length, SHEAR_ARROW_WIDTH_M, [x, ground + height, z]); + }); + return { geometry: merged([pole, ...arrows]), label: [x, ground + TIP_HEIGHT_M, z] }; +} + +function prevailingShape(spacing: SpacingD): OverlayShape { + const x = windArrowsX(spacing) - PREVAILING_OFFSET_M; + const origin = onGround([x, 0], PREVAILING_HEIGHT_M); + const geometry = arrowAlongX(PREVAILING_LENGTH_M, PREVAILING_WIDTH_M, origin); + return { geometry, label: [x + PREVAILING_LENGTH_M / 2, origin[1], 0] }; +} + +const SHAPES: Readonly OverlayShape>> = { + accessRoads: (spacing) => routesShape(spacing, ROAD_LIFT_M, ROAD_WIDTH_M, ROAD_THICKNESS_M), + collectorCables: (spacing) => routesShape(spacing, CABLE_LIFT_M, CABLE_WIDTH_M, CABLE_WIDTH_M), + spacingMarker: markerShape, + windArrows: windArrowsShape, + shearProfile: shearShape, + prevailingWind: prevailingShape, +}; + +const OVERLAY_PARTS = Object.keys(SHAPES) as OverlayPart[]; + +interface Overlay { + group: Group; + anchor: Object3D; + mesh?: Mesh; +} + +export class FarmOverlays { + private readonly tracker = new ResourceTracker(); + private readonly materials: MaterialLibrary; + private readonly overlays: Record; + private spacing?: SpacingD; + + constructor(context: PlaceholderContext, parent: Group, labels: Labels) { + this.materials = context.materials; + context.tracker.track(this.tracker); + this.overlays = Object.fromEntries( + OVERLAY_PARTS.map((part) => { + const group = namedGroup(part, parent); + const anchor = anchorAt(group, 0, 0, 0); + labels.set(part, anchor); + return [part, { group, anchor }]; + }), + ) as Record; + } + + group(part: OverlayPart): Group { + return this.overlays[part].group; + } + + layOut(spacing: SpacingD): void { + if (spacing === this.spacing) return; + this.spacing = spacing; + this.tracker.dispose(); + const context = { materials: this.materials, tracker: this.tracker }; + OVERLAY_PARTS.forEach((part) => { + const overlay = this.overlays[part]; + const shape = SHAPES[part](spacing); + overlay.mesh?.removeFromParent(); + overlay.mesh = partMesh(context, part, shape.geometry, overlay.group); + overlay.anchor.position.set(...shape.label); + }); + } +} diff --git a/explainers/wind-farm/src/scene/placeholder/regions.ts b/explainers/wind-farm/src/scene/placeholder/regions.ts new file mode 100644 index 00000000..086f95ef --- /dev/null +++ b/explainers/wind-farm/src/scene/placeholder/regions.ts @@ -0,0 +1,93 @@ +import { Box3, Vector3 } from 'three'; +import type { RegionId, SpacingD } from '../../ids'; +import { + FARM_TERRAIN, + MAX_PLUME_D, + ROTOR_DIAMETER_M, + ROTOR_RADIUS_M, + SUBSTATION, + TIP_HEIGHT_M, + TURBINE_GEOMETRY, + farmLayout, + terrainHeight, +} from '../../model'; + +type FarmRegionId = Extract; + +const [HUB_X, HUB_Y] = TURBINE_GEOMETRY.hub; +const ROTOR_REACH_M = Math.abs(HUB_X) + ROTOR_RADIUS_M; +const ROTOR_DEPTH_M = TURBINE_GEOMETRY.spinnerRadius * 2; +export const SUBSTATION_HEIGHT_M = 12; + +function turbineRegion(): Box3 { + return new Box3( + new Vector3(-ROTOR_REACH_M, 0, -ROTOR_REACH_M), + new Vector3(ROTOR_REACH_M, TIP_HEIGHT_M, ROTOR_REACH_M), + ); +} + +function nacelleRegion(): Box3 { + const { nacelle, cooler, spinnerRadius } = TURBINE_GEOMETRY; + return new Box3( + new Vector3(HUB_X - spinnerRadius, nacelle.minY, -nacelle.halfWidth), + new Vector3(nacelle.maxX, cooler.topY, nacelle.halfWidth), + ); +} + +function rotorRegion(): Box3 { + return new Box3( + new Vector3(HUB_X - ROTOR_DEPTH_M, HUB_Y - ROTOR_RADIUS_M, -ROTOR_RADIUS_M), + new Vector3(HUB_X + ROTOR_DEPTH_M, HUB_Y + ROTOR_RADIUS_M, ROTOR_RADIUS_M), + ); +} + +function farmRegion(spacing: SpacingD): Box3 { + const box = new Box3(); + farmLayout(spacing).forEach(({ x, z }) => { + const ground = terrainHeight(x, z); + box.expandByPoint(new Vector3(x - ROTOR_RADIUS_M, ground, z - ROTOR_RADIUS_M)); + box.expandByPoint(new Vector3(x + ROTOR_RADIUS_M, ground + TIP_HEIGHT_M, z + ROTOR_RADIUS_M)); + }); + return box; +} + +function wakesRegion(farm: Box3): Box3 { + const box = farm.clone(); + box.max.x = Math.min(FARM_TERRAIN.maxX, box.max.x + MAX_PLUME_D * ROTOR_DIAMETER_M); + return box; +} + +function gridRegion(farm: Box3): Box3 { + const ground = terrainHeight(SUBSTATION.x, SUBSTATION.z); + return farm + .clone() + .expandByPoint( + new Vector3(SUBSTATION.x - SUBSTATION.width / 2, ground, SUBSTATION.z - SUBSTATION.depth / 2), + ) + .expandByPoint( + new Vector3( + SUBSTATION.x + SUBSTATION.width / 2, + ground + SUBSTATION_HEIGHT_M, + SUBSTATION.z + SUBSTATION.depth / 2, + ), + ); +} + +export function farmRegions(spacing: SpacingD): Readonly> { + const farm = farmRegion(spacing); + return { farm, wakes: wakesRegion(farm), grid: gridRegion(farm) }; +} + +export function buildRegions(spacing: SpacingD): Record { + return { + turbine: turbineRegion(), + nacelle: nacelleRegion(), + rotor: rotorRegion(), + ...farmRegions(spacing), + }; +} + +export function refreshFarmRegions(regions: Record, spacing: SpacingD): void { + const next = farmRegions(spacing); + (Object.keys(next) as FarmRegionId[]).forEach((id) => regions[id].copy(next[id])); +} diff --git a/explainers/wind-farm/src/scene/placeholder/turbine.ts b/explainers/wind-farm/src/scene/placeholder/turbine.ts new file mode 100644 index 00000000..c1279865 --- /dev/null +++ b/explainers/wind-farm/src/scene/placeholder/turbine.ts @@ -0,0 +1,290 @@ +import { CircleGeometry, RingGeometry, SphereGeometry } from 'three'; +import type { Group, Mesh, Object3D } from 'three'; +import { anchorAt } from '@core/scene/parts'; +import type { AnchorId, AssemblyState, Point } from '../../ids'; +import { + BLADE_LENGTH_M, + MAX_CHORD_M, + ROTOR_DIAMETER_M, + ROTOR_RADIUS_M, + TURBINE_GEOMETRY, + TURBINE_LAND, +} from '../../model'; +import { + PLUME_REFERENCE_M, + bearingTurn, + boxAt, + boxBetween, + degrees, + groundPath, + label, + merged, + namedGroup, + partMesh, + plumeGeometry, + ribbon, + upright, +} from './build'; +import type { Labels, PlaceholderContext, PlaceholderScene } from './build'; +import { buildInterior } from './interior'; + +const GEOMETRY = TURBINE_GEOMETRY; +const [HUB_X, HUB_Y] = GEOMETRY.hub; +const NACELLE = GEOMETRY.nacelle; +const LAND_SEGMENTS = 64; +const FOUNDATION_HEIGHT_M = 1; +const PANEL_M = 0.1; +const COOLER_HALF_WIDTH_M = 1.8; +const BLADE_COUNT = 3; +const BLADE_THICKNESS_M = 0.8; +const RING_WIDTH_M = 1; +const RING_SEGMENTS = 96; +const SPINNER_SEGMENTS = 24; +const CABLE_LIFT_M = 0.3; +const CABLE_WIDTH_M = 0.8; +const STREAMLINE_START_X = -450; +const STREAMLINE_END_X = 900; +const STREAMLINE_WIDTH_M = 0.8; +const STREAMLINE_ROWS = 4; +const STREAMLINE_COLUMNS = 6; +const STREAMLINE_SPREAD = 0.8; +const LAND_LABEL: Point = [-200, 0, 200]; +const TOWER_LABEL_Y = 50; +const CABLE_LABEL_X = 60; +const STREAMLINE_LABEL: Point = [-300, HUB_Y + ROTOR_RADIUS_M, 0]; + +interface TurbineParts { + yaw: Group; + rotor: Group; + blades: readonly Object3D[]; + openShell: Mesh; + interior: ReturnType; + streamlines: Group; + wake: Group; + wakeMesh: Mesh; + towerCable: Group; +} + +function buildGround(context: PlaceholderContext, root: Group, labels: Labels): Group { + const { towerTopY, towerBaseDiameter, towerTopDiameter, foundationRadius, transformer } = + GEOMETRY; + partMesh( + context, + 'land', + new CircleGeometry(TURBINE_LAND.radius, LAND_SEGMENTS).rotateX(-Math.PI / 2), + root, + ); + partMesh( + context, + 'foundation', + upright(foundationRadius, foundationRadius, FOUNDATION_HEIGHT_M), + root, + ); + partMesh(context, 'tower', upright(towerBaseDiameter / 2, towerTopDiameter / 2, towerTopY), root); + const transformerCentre: Point = [ + transformer.centre[0], + transformer.size[1] / 2, + transformer.centre[2], + ]; + partMesh(context, 'transformer', boxAt(transformer.size, transformerCentre), root); + const towerCable = namedGroup('towerCable', root); + const cableZ = transformer.centre[2]; + const cablePath = groundPath( + [ + [transformer.centre[0], cableZ], + [TURBINE_LAND.radius, cableZ], + ], + CABLE_LIFT_M, + ); + partMesh(context, 'towerCable', ribbon(cablePath, CABLE_WIDTH_M, CABLE_LIFT_M), towerCable); + label(labels, 'land', root, LAND_LABEL); + label(labels, 'foundation', root, [0, FOUNDATION_HEIGHT_M, foundationRadius]); + label(labels, 'tower', root, [0, TOWER_LABEL_Y, towerBaseDiameter / 2]); + label(labels, 'transformer', root, [ + transformer.centre[0], + transformer.size[1], + transformer.centre[2], + ]); + label(labels, 'towerCable', towerCable, [CABLE_LABEL_X, CABLE_LIFT_M, cableZ]); + return towerCable; +} + +function shellPanels(open: boolean): readonly [Point, Point][] { + const { minX, maxX, minY, maxY, halfWidth } = NACELLE; + if (open) { + return [ + [ + [minX, maxY - PANEL_M, 0], + [maxX, maxY, halfWidth], + ], + [ + [minX, minY, halfWidth - PANEL_M], + [maxX, maxY, halfWidth], + ], + ]; + } + return [ + [ + [minX, minY, -halfWidth], + [maxX, minY + PANEL_M, halfWidth], + ], + [ + [minX, maxY - PANEL_M, -halfWidth], + [maxX, maxY, 0], + ], + [ + [minX, minY, -halfWidth], + [maxX, maxY, -halfWidth + PANEL_M], + ], + [ + [minX, minY, -halfWidth], + [minX + PANEL_M, maxY, halfWidth], + ], + [ + [maxX - PANEL_M, minY, -halfWidth], + [maxX, maxY, halfWidth], + ], + ]; +} + +function shellMesh(context: PlaceholderContext, frame: Group, open: boolean): Mesh { + const panels = shellPanels(open).map(([min, max]) => boxBetween(min, max)); + return partMesh(context, 'nacelle', merged(panels), frame); +} + +function buildNacelle(context: PlaceholderContext, frame: Group, labels: Labels): Mesh { + const { cooler } = GEOMETRY; + shellMesh(context, frame, false); + const openShell = shellMesh(context, frame, true); + const coolerBox = boxBetween( + [cooler.minX, NACELLE.maxY, -COOLER_HALF_WIDTH_M], + [cooler.maxX, cooler.topY, COOLER_HALF_WIDTH_M], + ); + partMesh(context, 'cooler', coolerBox, frame); + label(labels, 'nacelle', frame, [ + (NACELLE.minX + NACELLE.maxX) / 2, + NACELLE.maxY, + NACELLE.halfWidth, + ]); + label(labels, 'cooler', frame, [(cooler.minX + cooler.maxX) / 2, cooler.topY, 0]); + return openShell; +} + +function buildBlades(context: PlaceholderContext, rotor: Group): readonly Object3D[] { + const root = GEOMETRY.bladeRootRadius; + const geometry = boxAt( + [BLADE_THICKNESS_M, BLADE_LENGTH_M, MAX_CHORD_M], + [0, root + BLADE_LENGTH_M / 2, 0], + ); + return Array.from({ length: BLADE_COUNT }, (_, index) => { + const arm = namedGroup(`bladeArm${index}`, rotor); + arm.rotation.x = (index * 2 * Math.PI) / BLADE_COUNT; + return partMesh(context, 'blades', geometry, arm); + }); +} + +function buildRotor(context: PlaceholderContext, frame: Group, labels: Labels) { + const rotor = namedGroup('rotor', frame); + rotor.position.set(HUB_X, HUB_Y, 0); + const spinner = new SphereGeometry(GEOMETRY.spinnerRadius, SPINNER_SEGMENTS, SPINNER_SEGMENTS); + partMesh(context, 'hub', spinner, rotor); + const blades = buildBlades(context, rotor); + const ring = new RingGeometry(ROTOR_RADIUS_M - RING_WIDTH_M, ROTOR_RADIUS_M, RING_SEGMENTS) + .rotateY(Math.PI / 2) + .translate(HUB_X, HUB_Y, 0); + partMesh(context, 'sweptArea', ring, frame); + label(labels, 'hub', frame, [HUB_X - GEOMETRY.spinnerRadius, HUB_Y, 0]); + label(labels, 'blades', frame, [HUB_X, HUB_Y + ROTOR_RADIUS_M / 2, 0]); + label(labels, 'sweptArea', frame, [HUB_X, HUB_Y + ROTOR_RADIUS_M, 0]); + return { rotor, blades }; +} + +function streamlineGeometry() { + const reach = ROTOR_RADIUS_M * STREAMLINE_SPREAD; + const offsets = (count: number) => + Array.from({ length: count }, (_, index) => -reach + (2 * reach * index) / (count - 1)); + const lines = offsets(STREAMLINE_ROWS).flatMap((dy) => + offsets(STREAMLINE_COLUMNS).map((dz) => + boxBetween( + [STREAMLINE_START_X, HUB_Y + dy, dz], + [STREAMLINE_END_X, HUB_Y + dy + STREAMLINE_WIDTH_M, dz + STREAMLINE_WIDTH_M], + ), + ), + ); + return merged(lines); +} + +function buildStreamlines(context: PlaceholderContext, root: Group, labels: Labels): Group { + const streamlines = namedGroup('streamlines', root); + partMesh(context, 'streamlinesGroup', streamlineGeometry(), streamlines); + label(labels, 'streamlinesGroup', streamlines, STREAMLINE_LABEL); + return streamlines; +} + +function buildHeroWake(context: PlaceholderContext, frame: Group, labels: Labels) { + const wake = namedGroup('heroWake', frame); + const wakeMesh = partMesh(context, 'heroWake', plumeGeometry(), wake); + wakeMesh.position.set(HUB_X, HUB_Y, 0); + label(labels, 'heroWake', wake, [HUB_X + ROTOR_DIAMETER_M, HUB_Y + ROTOR_RADIUS_M, 0]); + return { wake, wakeMesh }; +} + +function buildYaw(root: Group): { yaw: Group; frame: Group } { + const yaw = namedGroup('yawPivot', root); + yaw.position.set(0, GEOMETRY.towerTopY, 0); + const frame = namedGroup('nacelleFrame', yaw); + frame.position.set(0, -GEOMETRY.towerTopY, 0); + return { yaw, frame }; +} + +function applyState(parts: TurbineParts, state: AssemblyState): void { + const { rotor, view, wind, farm } = state; + parts.yaw.rotation.y = bearingTurn(rotor.yawDeg); + parts.blades.forEach((blade) => (blade.rotation.y = degrees(rotor.pitchDeg))); + parts.interior.group.visible = view.cutaway; + parts.openShell.visible = !view.cutaway; + parts.streamlines.visible = view.streamlines; + parts.streamlines.rotation.y = bearingTurn(wind.fromDeg); + parts.towerCable.visible = view.cables; + parts.wake.visible = view.wakes && farm.plumeLengthD > 0; + if (parts.wake.visible) + parts.wakeMesh.scale.x = (farm.plumeLengthD * ROTOR_DIAMETER_M) / PLUME_REFERENCE_M; +} + +export function buildTurbineScene(context: PlaceholderContext): PlaceholderScene { + const root = namedGroup('turbineScene'); + const labels: Labels = new Map(); + const towerCable = buildGround(context, root, labels); + const { yaw, frame } = buildYaw(root); + const openShell = buildNacelle(context, frame, labels); + const { rotor, blades } = buildRotor(context, frame, labels); + const interior = buildInterior(context, frame, labels); + const streamlines = buildStreamlines(context, root, labels); + const { wake, wakeMesh } = buildHeroWake(context, frame, labels); + const parts: TurbineParts = { + yaw, + rotor, + blades, + openShell, + interior, + streamlines, + wake, + wakeMesh, + towerCable, + }; + const anchors: Partial> = { + hub: anchorAt(frame, HUB_X, HUB_Y, 0), + yawPivot: yaw, + towerBase: anchorAt(root, 0, 0, 0), + }; + return { + root, + labels, + anchors, + setState: (state) => applyState(parts, state), + setAzimuth: (azimuth) => { + rotor.rotation.x = azimuth; + interior.spinner.rotation.x = azimuth; + }, + }; +} diff --git a/explainers/wind-farm/src/scene/placeholderAssembly.dom.test.ts b/explainers/wind-farm/src/scene/placeholderAssembly.dom.test.ts new file mode 100644 index 00000000..7a8f6ea3 --- /dev/null +++ b/explainers/wind-farm/src/scene/placeholderAssembly.dom.test.ts @@ -0,0 +1,172 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { InstancedMesh, Vector3 } from 'three'; +import type { Box3, Object3D } from 'three'; +import { MaterialLibrary } from '@core/scene/materials'; +import { isShown } from '@core/scene/parts'; +import { createSceneTextures } from '@core/scene/textures'; +import { FARM_PART_IDS, PART_IDS, TURBINE_PART_IDS } from '../ids'; +import type { + AnchorId, + AssemblyState, + PartId, + RegionId, + SceneId, + SpacingD, + ViewOptions, +} from '../ids'; +import { TURBINE_COUNT, farmLayout } from '../model'; +import type { Assembly } from './assembly'; +import { createPlaceholderAssembly } from './placeholderAssembly'; + +const ANCHOR_IDS: readonly AnchorId[] = [ + 'hub', + 'yawPivot', + 'towerBase', + 'farmCentre', + 'heroSite', + 'substation', +]; +const REGION_IDS: readonly RegionId[] = ['farm', 'wakes', 'grid', 'turbine', 'nacelle', 'rotor']; +const INTERIOR_PARTS: readonly PartId[] = [ + 'bedplate', + 'pitchCylinders', + 'mainBearing', + 'mainShaft', + 'gearbox', + 'brakeDisc', + 'generator', + 'converter', + 'yawDrives', +]; +const ALL_VIEWS: ViewOptions = { + streamlines: true, + wakes: true, + cables: true, + labels: true, + cutaway: true, +}; +const QUIET_VIEWS: ViewOptions = { + streamlines: false, + wakes: false, + cables: false, + labels: true, + cutaway: false, +}; +const DEFAULT_SPACING: SpacingD = 7; +const FRAME_SECONDS = 1 / 60; +const TURNING_RPM = 10; + +function stateWith( + scene: SceneId, + view: ViewOptions, + rpm = TURNING_RPM, + spacing = DEFAULT_SPACING, +): AssemblyState { + return { + scene, + phase: 0, + playing: false, + wind: { speed: 9, fromDeg: 270, induction: 0.25 }, + rotor: { rpm, pitchDeg: 0, yawDeg: 270, state: 'partial', braked: false, powerShare: 0.5 }, + farm: { + spacing, + sites: farmLayout(spacing), + deficits: Array.from({ length: TURBINE_COUNT }, () => 0), + plumeLengthD: 8, + plumeStrength: 0.3, + outputShare: 0.5, + }, + view, + }; +} + +function isFilled(box: Box3): boolean { + return !box.isEmpty() && box.max.x > box.min.x && box.max.y > box.min.y && box.max.z > box.min.z; +} + +function worldPoint(object: Object3D): number[] { + object.updateWorldMatrix(true, false); + return object + .getWorldPosition(new Vector3()) + .toArray() + .map((value) => Math.round(value * 10) / 10); +} + +function shownParts(parts: readonly PartId[]): boolean[] { + return parts.map((id) => isShown(assembly.labelAnchors().get(id)!)); +} + +let textures: ReturnType; +let assembly: Assembly; + +beforeAll(() => { + textures = createSceneTextures(); + assembly = createPlaceholderAssembly( + { materials: new MaterialLibrary(), textures }, + stateWith('turbine', ALL_VIEWS), + ); +}); + +afterAll(() => { + assembly.dispose(); + textures.dispose(); +}); + +describe('placeholder assembly', () => { + it('has a label anchor for every part', () => { + const anchors = assembly.labelAnchors(); + expect(PART_IDS.filter((id) => !anchors.has(id))).toEqual([]); + }); + + it('resolves every anchor and region', () => { + ANCHOR_IDS.forEach((id) => expect(assembly.anchor(id)).toBeDefined()); + REGION_IDS.forEach((id) => expect(isFilled(assembly.region(id)), id).toBe(true)); + }); + + it('shows only the labels of the visible scene', () => { + assembly.setState(stateWith('farm', ALL_VIEWS)); + expect(shownParts(TURBINE_PART_IDS).every((shown) => !shown)).toBe(true); + expect(shownParts(FARM_PART_IDS).every(Boolean)).toBe(true); + assembly.setState(stateWith('turbine', ALL_VIEWS)); + expect(shownParts(TURBINE_PART_IDS).every(Boolean)).toBe(true); + expect(shownParts(FARM_PART_IDS).every((shown) => !shown)).toBe(true); + }); + + it('hides the interior labels while the nacelle is closed', () => { + assembly.setState(stateWith('turbine', { ...ALL_VIEWS, cutaway: false })); + expect(shownParts(INTERIOR_PARTS).every((shown) => !shown)).toBe(true); + expect(isShown(assembly.labelAnchors().get('nacelle')!)).toBe(true); + }); + + it('draws one instance per farm turbine', () => { + const meshes: InstancedMesh[] = []; + assembly.root.traverse((object) => { + if (object instanceof InstancedMesh) meshes.push(object); + }); + expect(meshes.length).toBeGreaterThan(0); + meshes.forEach((mesh) => expect(mesh.count).toBe(TURBINE_COUNT)); + }); + + it('keeps animating only while something moves', () => { + assembly.setState(stateWith('turbine', QUIET_VIEWS)); + expect(assembly.update(FRAME_SECONDS, 0)).toBe(true); + assembly.setState(stateWith('turbine', QUIET_VIEWS, 0)); + expect(assembly.update(FRAME_SECONDS, 0)).toBe(false); + }); + + it('turns the hub toward the bearing the wind comes from', () => { + const west = stateWith('turbine', ALL_VIEWS); + assembly.setState(west); + expect(worldPoint(assembly.anchor('hub'))).toEqual([-7, 105, 0]); + assembly.setState({ ...west, rotor: { ...west.rotor, yawDeg: 0 } }); + expect(worldPoint(assembly.anchor('hub'))).toEqual([0, 105, -7]); + }); + + it('moves the farm region with the spacing', () => { + assembly.setState(stateWith('farm', ALL_VIEWS, TURNING_RPM, 5)); + const tight = assembly.region('farm').clone(); + assembly.setState(stateWith('farm', ALL_VIEWS, TURNING_RPM, 9)); + expect(assembly.region('farm').equals(tight)).toBe(false); + expect(assembly.region('farm').max.x).toBeGreaterThan(tight.max.x); + }); +}); diff --git a/explainers/wind-farm/src/scene/placeholderAssembly.ts b/explainers/wind-farm/src/scene/placeholderAssembly.ts new file mode 100644 index 00000000..26c9f592 --- /dev/null +++ b/explainers/wind-farm/src/scene/placeholderAssembly.ts @@ -0,0 +1,125 @@ +import { Group } from 'three'; +import type { Box3, Object3D } from 'three'; +import type { MaterialLibrary } from '@core/scene/materials'; +import { ResourceTracker } from '@core/scene/resources'; +import { PART_IDS } from '../ids'; +import type { AnchorId, AssemblyState, PartId, RegionId } from '../ids'; +import { advanceAzimuth } from '../model'; +import type { Assembly, AssemblyResources } from './assembly'; +import type { PlaceholderScene } from './placeholder/build'; +import { buildFarmScene } from './placeholder/farm'; +import { buildRegions, refreshFarmRegions } from './placeholder/regions'; +import { buildTurbineScene } from './placeholder/turbine'; + +const ROOT_NAME = 'windFarmPlaceholder'; +const ANCHOR_IDS: readonly AnchorId[] = [ + 'hub', + 'yawPivot', + 'towerBase', + 'farmCentre', + 'heroSite', + 'substation', +]; + +function collectLabels(scenes: readonly PlaceholderScene[]): ReadonlyMap { + const found = new Map(scenes.flatMap((scene) => [...scene.labels])); + return new Map( + PART_IDS.map((id) => { + const anchor = found.get(id); + if (!anchor) throw new Error(`No label anchor for part ${id}`); + return [id, anchor]; + }), + ); +} + +function collectAnchors(scenes: readonly PlaceholderScene[]): Readonly> { + const provided: Partial> = Object.assign( + {}, + ...scenes.map((scene) => scene.anchors), + ); + return Object.fromEntries( + ANCHOR_IDS.map((id) => { + const anchor = provided[id]; + if (!anchor) throw new Error(`No scene anchor ${id}`); + return [id, anchor]; + }), + ) as Record; +} + +function keepsMoving(state: AssemblyState): boolean { + const { rotor, view, farm } = state; + return rotor.rpm > 0 || view.streamlines || (view.cables && farm.outputShare > 0); +} + +class PlaceholderAssembly implements Assembly { + readonly root = new Group(); + private readonly materials: MaterialLibrary; + private readonly tracker = new ResourceTracker(); + private readonly turbine: PlaceholderScene; + private readonly farm: PlaceholderScene; + private readonly labels: ReadonlyMap; + private readonly anchors: Readonly>; + private readonly regions: Record; + private state: AssemblyState; + private azimuth = 0; + + constructor(resources: AssemblyResources, state: AssemblyState) { + this.materials = resources.materials; + this.root.name = ROOT_NAME; + const context = { materials: resources.materials, tracker: this.tracker }; + this.turbine = buildTurbineScene(context); + this.farm = buildFarmScene(context); + this.root.add(this.turbine.root, this.farm.root); + this.labels = collectLabels([this.turbine, this.farm]); + this.anchors = collectAnchors([this.turbine, this.farm]); + this.regions = buildRegions(state.farm.spacing); + this.state = state; + this.setState(state); + } + + setState(state: AssemblyState): void { + if (state.farm.spacing !== this.state.farm.spacing) + refreshFarmRegions(this.regions, state.farm.spacing); + this.state = state; + this.turbine.root.visible = state.scene === 'turbine'; + this.farm.root.visible = state.scene === 'farm'; + this.turbine.setState(state); + this.farm.setState(state); + this.applyAzimuth(); + } + + update(deltaSeconds: number): boolean { + this.azimuth = advanceAzimuth(this.azimuth, this.state.rotor.rpm, deltaSeconds); + this.applyAzimuth(); + return keepsMoving(this.state); + } + + labelAnchors(): ReadonlyMap { + return this.labels; + } + + anchor(id: AnchorId): Object3D { + return this.anchors[id]; + } + + region(id: RegionId): Box3 { + return this.regions[id]; + } + + dispose(): void { + this.materials.clearRegistered(); + this.tracker.dispose(); + } + + private applyAzimuth(): void { + this.turbine.setAzimuth(this.azimuth); + this.farm.setAzimuth(this.azimuth); + } +} + +export function createPlaceholderAssembly( + resources: AssemblyResources, + state: AssemblyState, +): Assembly { + return new PlaceholderAssembly(resources, state); +} From b860a8e7af15e92463731689f1e1499f3f7f6062 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:16:25 +0200 Subject: [PATCH 004/132] feat(wind-farm): add the manifest and a placeholder cover --- explainers/wind-farm/explainer.json | 12 ++++++++++++ explainers/wind-farm/public/cover.webp | Bin 0 -> 10844 bytes 2 files changed, 12 insertions(+) create mode 100644 explainers/wind-farm/explainer.json create mode 100644 explainers/wind-farm/public/cover.webp diff --git a/explainers/wind-farm/explainer.json b/explainers/wind-farm/explainer.json new file mode 100644 index 00000000..d5ca2dd1 --- /dev/null +++ b/explainers/wind-farm/explainer.json @@ -0,0 +1,12 @@ +{ + "slug": "wind-farm", + "tags": ["energy", "mechanics", "weather"], + "cover": "cover.webp", + "entry": "src/index.ts", + "chapters": "chapters.html", + "locales": ["en", "zh", "es", "uk", "pt", "fr", "de", "ja"], + "social": { + "image": "social/og-image.png", + "alt": "A wind farm of white turbines on green fields under a blue sky, beside the title How a wind farm works" + } +} diff --git a/explainers/wind-farm/public/cover.webp b/explainers/wind-farm/public/cover.webp new file mode 100644 index 0000000000000000000000000000000000000000..94fbbcac16b5a2d4a58bdfca55f7c8f0edec3e4d GIT binary patch literal 10844 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 T@!ZmJ2J?V9^naYTzq|hjxa{ti literal 0 HcmV?d00001 From 7cd15b818aad9bb78c0915204ca1c2c051c3a3b7 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:16:25 +0200 Subject: [PATCH 005/132] feat(wind-farm): add the social card and a placeholder share image --- .../wind-farm/public/social/og-image.png | Bin 0 -> 57936 bytes scripts/cards/wind-farm.html | 163 ++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 explainers/wind-farm/public/social/og-image.png create mode 100644 scripts/cards/wind-farm.html diff --git a/explainers/wind-farm/public/social/og-image.png b/explainers/wind-farm/public/social/og-image.png new file mode 100644 index 0000000000000000000000000000000000000000..10e90930bf37479cc6c534580a48c1b0d31415d5 GIT binary patch literal 57936 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 Sbbnz00000 + + + + How a wind farm works: social card + + + + + + +
+ +
+
+

+ + Renewable energy +

+

How a wind farm works

+

+ Twenty-seven turbines and one windy day: how blades, gears and cables turn moving air into + power for the grid. +

+
    +
  1. Farm
  2. +
  3. Blades
  4. +
  5. Nacelle
  6. +
  7. Curve
  8. +
  9. Wakes
  10. +
  11. Grid
  12. +
+
+

whatmakesittick.github.io/wind-farm

+ + + From 398c1fed698ccd791072184f5061cc96c792519c Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:17:04 +0200 Subject: [PATCH 006/132] feat(wind-farm): add the part labels and their priority --- .../wind-farm/src/scene/partInfo.test.ts | 53 +++++++++++++++++++ explainers/wind-farm/src/scene/partInfo.ts | 53 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 explainers/wind-farm/src/scene/partInfo.test.ts create mode 100644 explainers/wind-farm/src/scene/partInfo.ts diff --git a/explainers/wind-farm/src/scene/partInfo.test.ts b/explainers/wind-farm/src/scene/partInfo.test.ts new file mode 100644 index 00000000..2dafa641 --- /dev/null +++ b/explainers/wind-farm/src/scene/partInfo.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { PART_IDS } from '../ids'; +import type { PartId } from '../ids'; +import { LABEL_PRIORITY, PART_INFO } from './partInfo'; + +const LEFT_PARTS: readonly PartId[] = [ + 'land', + 'foundation', + 'tower', + 'hub', + 'blades', + 'pitchCylinders', + 'mainBearing', + 'mainShaft', + 'yawDrives', + 'sweptArea', + 'streamlinesGroup', + 'farmLand', + 'prevailingWind', + 'windArrows', + 'shearProfile', + 'spacingMarker', + 'accessRoads', +]; +const RANKED_COUNT = 13; + +describe('part labels', () => { + it('names every part from its locale key', () => { + PART_IDS.forEach((id) => expect(PART_INFO[id].labelKey).toBe(`parts.${id}`)); + expect(Object.keys(PART_INFO)).toHaveLength(PART_IDS.length); + }); + + it('ranks every part exactly once, the farm turbines first and the substation second', () => { + expect([...LABEL_PRIORITY].sort()).toEqual([...PART_IDS].sort()); + expect(LABEL_PRIORITY.slice(0, 3)).toEqual(['farmTurbines', 'substation', 'nacelle']); + expect(LABEL_PRIORITY.indexOf('mainShaft')).toBe(RANKED_COUNT - 1); + }); + + it('keeps the unranked parts in their part order after the ranked ones', () => { + const rest = LABEL_PRIORITY.slice(RANKED_COUNT); + expect(rest).toEqual(PART_IDS.filter((id) => rest.includes(id))); + expect(rest[0]).toBe('land'); + }); + + it('labels the ground, the tower, the rotor and the wind on the left and the rest on the right', () => { + PART_IDS.forEach((id) => + expect(PART_INFO[id].side, id).toBe(LEFT_PARTS.includes(id) ? 'left' : 'right'), + ); + (['nacelle', 'gearbox', 'generator', 'substation', 'wakePlumes'] as const).forEach((id) => + expect(PART_INFO[id].side, id).toBe('right'), + ); + }); +}); diff --git a/explainers/wind-farm/src/scene/partInfo.ts b/explainers/wind-farm/src/scene/partInfo.ts new file mode 100644 index 00000000..cf9eb6cb --- /dev/null +++ b/explainers/wind-farm/src/scene/partInfo.ts @@ -0,0 +1,53 @@ +import type { PartInfo } from '@core/explainer'; +import type { LabelSide } from '@core/scene/labelLayout'; +import { PART_IDS } from '../ids'; +import type { PartId } from '../ids'; + +const LEFT_PARTS: ReadonlySet = new Set([ + 'land', + 'foundation', + 'tower', + 'hub', + 'blades', + 'pitchCylinders', + 'mainBearing', + 'mainShaft', + 'yawDrives', + 'sweptArea', + 'streamlinesGroup', + 'farmLand', + 'prevailingWind', + 'windArrows', + 'shearProfile', + 'spacingMarker', + 'accessRoads', +]); + +const RANKED_FIRST: readonly PartId[] = [ + 'farmTurbines', + 'substation', + 'nacelle', + 'hub', + 'blades', + 'gearbox', + 'generator', + 'wakePlumes', + 'collectorCables', + 'tower', + 'brakeDisc', + 'pitchCylinders', + 'mainShaft', +]; + +function labelSide(id: PartId): LabelSide { + return LEFT_PARTS.has(id) ? 'left' : 'right'; +} + +export const PART_INFO: Record = Object.fromEntries( + PART_IDS.map((id) => [id, { labelKey: `parts.${id}`, side: labelSide(id) }]), +) as Record; + +export const LABEL_PRIORITY: readonly PartId[] = [ + ...RANKED_FIRST, + ...PART_IDS.filter((id) => !RANKED_FIRST.includes(id)), +]; From 3f4a96517866de0f90abb3afc4d1d335047444d1 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:17:04 +0200 Subject: [PATCH 007/132] feat(wind-farm): add the camera views and the scene controller --- explainers/wind-farm/src/scene/cameraViews.ts | 59 ++++++++++++ explainers/wind-farm/src/scene/controller.ts | 90 +++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 explainers/wind-farm/src/scene/cameraViews.ts create mode 100644 explainers/wind-farm/src/scene/controller.ts diff --git a/explainers/wind-farm/src/scene/cameraViews.ts b/explainers/wind-farm/src/scene/cameraViews.ts new file mode 100644 index 00000000..efd3271b --- /dev/null +++ b/explainers/wind-farm/src/scene/cameraViews.ts @@ -0,0 +1,59 @@ +import type { Direction, FramedView } from '@core/scene/cameraViews'; +import type { AnchorId, CameraView, RegionId } from '../ids'; + +export const STAGE_VARIANTS = ['phone', 'desktop'] as const; + +export type StageVariant = (typeof STAGE_VARIANTS)[number]; + +export interface StageView extends FramedView { + direction: Readonly>; +} + +const PHONE_STAGE_WIDTH_PX = 600; + +export const FOLLOWED_ANCHOR: AnchorId = 'yawPivot'; + +export function stageVariant(width: number): StageVariant { + return width < PHONE_STAGE_WIDTH_PX ? 'phone' : 'desktop'; +} + +export const CAMERA_VIEWS: Readonly> = { + farmAerial: { + region: 'farm', + direction: { phone: [-0.3, 1.15, 1], desktop: [-0.6, 1.1, 1] }, + margin: 1.05, + distance: { min: 2000, max: 20000 }, + }, + turbineTall: { + region: 'turbine', + direction: { phone: [-1, 0.16, 0.45], desktop: [-1, 0.16, 0.7] }, + margin: 1.1, + distance: { min: 150, max: 2500 }, + }, + nacelleCutaway: { + region: 'nacelle', + direction: { phone: [0.25, 0.9, 1], desktop: [0.1, 0.75, 1] }, + margin: 1.2, + follow: 'heading', + distance: { min: 8, max: 150 }, + }, + rotorQuarter: { + region: 'rotor', + direction: { phone: [-1, 0.16, 0.75], desktop: [-0.8, 0.16, 1] }, + margin: 1.1, + follow: 'heading', + distance: { min: 80, max: 1200 }, + }, + wakeStreaks: { + region: 'wakes', + direction: { phone: [-0.35, 0.55, 1], desktop: [-0.2, 0.47, 1] }, + margin: 1.05, + distance: { min: 1500, max: 18000 }, + }, + gridSubstation: { + region: 'grid', + direction: { phone: [0.6, 1.75, 1], desktop: [0.75, 1.65, 1] }, + margin: 1.05, + distance: { min: 600, max: 15000 }, + }, +}; diff --git a/explainers/wind-farm/src/scene/controller.ts b/explainers/wind-farm/src/scene/controller.ts new file mode 100644 index 00000000..1d978ab6 --- /dev/null +++ b/explainers/wind-farm/src/scene/controller.ts @@ -0,0 +1,90 @@ +import type { Object3D } from 'three'; +import { CameraViews } from '@core/scene/cameraViews'; +import type { SceneShell } from '@core/scene/shell'; +import { whenIdle } from '@core/scene/warmUp'; +import type { AssemblyState, CameraView, RegionId, SceneId } from '../ids'; +import { createAssembly } from './assembly'; +import type { Assembly } from './assembly'; +import { CAMERA_VIEWS, FOLLOWED_ANCHOR, stageVariant } from './cameraViews'; +import type { StageVariant } from './cameraViews'; + +export type WindFarmControllerDependencies = Pick< + SceneShell, + 'scene' | 'materials' | 'textures' | 'labels' | 'rig' | 'viewport' +>; + +export type ViewFramer = Pick, 'frame'>; + +const SCENE_BOUNDS: Readonly> = { farm: 'farm', turbine: 'turbine' }; + +export class WindFarmController { + readonly views: ViewFramer; + private readonly dependencies: WindFarmControllerDependencies; + private readonly cameraViews: CameraViews; + private assembly: Assembly | null = null; + private boundScene: SceneId | null = null; + private cancelWarmUp: () => void = () => {}; + + constructor(dependencies: WindFarmControllerDependencies) { + this.dependencies = dependencies; + this.cameraViews = new CameraViews(dependencies.rig, { + views: CAMERA_VIEWS, + region: (id) => this.assembly?.region(id) ?? null, + anchor: () => this.assembly?.anchor(FOLLOWED_ANCHOR) ?? null, + }); + this.views = { + frame: (view, animate, variant) => + this.cameraViews.frame(view, animate, variant ?? this.stageVariant()), + }; + } + + build(state: AssemblyState): void { + const { scene, materials, textures, labels } = this.dependencies; + this.dispose(); + const assembly = createAssembly({ materials, textures }, state); + this.assembly = assembly; + scene.add(assembly.root); + labels.attach(assembly.labelAnchors()); + this.boundTo(state.scene); + this.cancelWarmUp = whenIdle(() => assembly.warmUp?.((object) => this.compileHidden(object))); + } + + setState(state: AssemblyState): void { + this.assembly?.setState(state); + this.boundTo(state.scene); + } + + update(deltaSeconds: number): boolean { + const { camera, controls } = this.dependencies.rig; + return ( + this.assembly?.update(deltaSeconds, camera.position.distanceTo(controls.target)) ?? false + ); + } + + dispose(): void { + this.cancelWarmUp(); + this.assembly?.root.removeFromParent(); + this.assembly?.dispose(); + this.assembly = null; + this.boundScene = null; + } + + private boundTo(scene: SceneId): void { + if (!this.assembly || scene === this.boundScene) return; + const bounds = this.assembly.region(SCENE_BOUNDS[scene]); + this.dependencies.rig.setBounds(bounds, bounds.min.y); + this.boundScene = scene; + } + + private stageVariant(): StageVariant { + return stageVariant(this.dependencies.viewport.element.clientWidth); + } + + private compileHidden(object: Object3D): void { + const { viewport, rig, scene } = this.dependencies; + const shown = object.visible; + object.visible = true; + void viewport.renderer.compileAsync(object, rig.camera, scene); + object.visible = shown; + } +} From 0c2bf416f2aea8cddb475d895bfc9800d23566d1 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:17:06 +0200 Subject: [PATCH 008/132] feat(wind-farm): add the turbine power and wake model --- explainers/wind-farm/src/model/index.ts | 2 + explainers/wind-farm/src/model/power.test.ts | 68 ++++++++++ explainers/wind-farm/src/model/power.ts | 29 ++++ explainers/wind-farm/src/model/wakes.test.ts | 135 +++++++++++++++++++ explainers/wind-farm/src/model/wakes.ts | 108 +++++++++++++++ 5 files changed, 342 insertions(+) create mode 100644 explainers/wind-farm/src/model/power.test.ts create mode 100644 explainers/wind-farm/src/model/power.ts create mode 100644 explainers/wind-farm/src/model/wakes.test.ts create mode 100644 explainers/wind-farm/src/model/wakes.ts diff --git a/explainers/wind-farm/src/model/index.ts b/explainers/wind-farm/src/model/index.ts index 4dfa8735..280b7978 100644 --- a/explainers/wind-farm/src/model/index.ts +++ b/explainers/wind-farm/src/model/index.ts @@ -3,3 +3,5 @@ export * from './curves'; export * from './day'; export * from './layout'; export * from './control'; +export * from './power'; +export * from './wakes'; diff --git a/explainers/wind-farm/src/model/power.test.ts b/explainers/wind-farm/src/model/power.test.ts new file mode 100644 index 00000000..18ab5f86 --- /dev/null +++ b/explainers/wind-farm/src/model/power.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { BETZ_LIMIT, RATED_KW } from './constants'; +import { + betzShare, + isGenerating, + powerCoefficient, + thrustCoefficient, + turbinePowerKw, + windPowerKw, +} from './power'; + +describe('turbine power', () => { + it('makes nothing below cut-in and from cut-out up', () => { + expect(turbinePowerKw(2.9)).toBe(0); + expect(turbinePowerKw(24.5)).toBe(0); + expect(turbinePowerKw(30)).toBe(0); + expect(isGenerating(3)).toBe(true); + expect(isGenerating(24.5)).toBe(false); + }); + + it('follows the published power curve between the knots', () => { + expect(turbinePowerKw(3)).toBe(81); + expect(turbinePowerKw(8)).toBe(2545); + expect(turbinePowerKw(7.5)).toBeCloseTo(2127, 9); + expect(turbinePowerKw(15)).toBe(RATED_KW); + expect(turbinePowerKw(24)).toBe(1283); + }); +}); + +describe('thrust coefficient', () => { + it('reads the thrust curve and holds it flat past its ends', () => { + expect(thrustCoefficient(8)).toBe(0.79); + expect(thrustCoefficient(3.5)).toBe(0.83); + expect(thrustCoefficient(22)).toBe(0.06); + }); + + it('drops to zero when the rotor is not running', () => { + expect(thrustCoefficient(2)).toBe(0); + expect(thrustCoefficient(25)).toBe(0); + }); +}); + +describe('power in the wind', () => { + it('grows with the cube of the wind at about 10.82 kW per (m/s)³', () => { + expect(windPowerKw(1)).toBeCloseTo(10.82, 2); + expect(windPowerKw(10)).toBeCloseTo(10_823.5, 0); + expect(windPowerKw(0)).toBe(0); + }); + + it('peaks the power coefficient near 0.46 from 6.5 to 8 m/s', () => { + expect(powerCoefficient(7)).toBeCloseTo(0.46, 2); + expect(powerCoefficient(8)).toBeCloseTo(0.46, 2); + const winds = Array.from({ length: 41 }, (_, index) => 3 + index * 0.5); + const peak = Math.max(...winds.map(powerCoefficient)); + expect(peak).toBeCloseTo(0.466, 3); + }); + + it('gives no power coefficient when the turbine makes nothing', () => { + expect(powerCoefficient(2)).toBe(0); + expect(powerCoefficient(25)).toBe(0); + }); + + it('stays below the Betz limit', () => { + expect(betzShare(8)).toBeCloseTo(powerCoefficient(8) / BETZ_LIMIT, 9); + expect(betzShare(8)).toBeCloseTo(0.775, 3); + expect(betzShare(8)).toBeLessThan(1); + }); +}); diff --git a/explainers/wind-farm/src/model/power.ts b/explainers/wind-farm/src/model/power.ts new file mode 100644 index 00000000..386a3958 --- /dev/null +++ b/explainers/wind-farm/src/model/power.ts @@ -0,0 +1,29 @@ +import { AIR_DENSITY, BETZ_LIMIT, CUT_IN_MS, CUT_OUT_MS, SWEPT_AREA_M2 } from './constants'; +import { POWER_CURVE, THRUST_CURVE, interpolateKnots } from './curves'; + +const WATTS_PER_KW = 1000; + +export function isGenerating(wind: number): boolean { + return wind >= CUT_IN_MS && wind < CUT_OUT_MS; +} + +export function turbinePowerKw(wind: number): number { + return isGenerating(wind) ? interpolateKnots(POWER_CURVE, wind) : 0; +} + +export function thrustCoefficient(wind: number): number { + return isGenerating(wind) ? interpolateKnots(THRUST_CURVE, wind) : 0; +} + +export function windPowerKw(wind: number): number { + return (0.5 * AIR_DENSITY * SWEPT_AREA_M2 * wind ** 3) / WATTS_PER_KW; +} + +export function powerCoefficient(wind: number): number { + const power = turbinePowerKw(wind); + return power > 0 ? power / windPowerKw(wind) : 0; +} + +export function betzShare(wind: number): number { + return powerCoefficient(wind) / BETZ_LIMIT; +} diff --git a/explainers/wind-farm/src/model/wakes.test.ts b/explainers/wind-farm/src/model/wakes.test.ts new file mode 100644 index 00000000..e71fc790 --- /dev/null +++ b/explainers/wind-farm/src/model/wakes.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from 'vitest'; +import type { FarmSite } from '../ids'; +import { MAX_PLUME_D, ROTOR_DIAMETER_M, ROTOR_RADIUS_M, TURBINE_COUNT } from './constants'; +import { farmLayout, HERO_SITE } from './layout'; +import { thrustCoefficient, turbinePowerKw } from './power'; +import { + axialInduction, + centrelineDeficit, + farmPowerKw, + initialDeficit, + overlapShare, + plumeLengthD, + wakeDeficits, + wakeLoss, +} from './wakes'; + +const THRUST_AT_8 = 0.79; +const LEAD: FarmSite = { row: 0, column: 0, x: 0, z: 0 }; + +function behind(xOverD: number, lateralM = 0): FarmSite { + return { row: 1, column: 0, x: xOverD * ROTOR_DIAMETER_M, z: lateralM }; +} + +describe('wake strength', () => { + it('starts the deficit at twice the axial induction', () => { + expect(initialDeficit(THRUST_AT_8)).toBeCloseTo(0.5417, 4); + expect(axialInduction(THRUST_AT_8)).toBeCloseTo(0.2709, 4); + expect(initialDeficit(0)).toBe(0); + }); + + it('leaves about 13 % less wind on the centreline 7 D behind at 8 m/s', () => { + expect(centrelineDeficit(thrustCoefficient(8), 7)).toBeCloseTo(0.129, 3); + expect(centrelineDeficit(THRUST_AT_8, 0)).toBe(initialDeficit(THRUST_AT_8)); + }); +}); + +describe('overlapShare', () => { + it('covers the whole rotor when the wake is wider and centred', () => { + expect(overlapShare(0, 100, ROTOR_RADIUS_M)).toBe(1); + expect(overlapShare(20, 100, ROTOR_RADIUS_M)).toBe(1); + }); + + it('misses the rotor once the circles part', () => { + expect(overlapShare(175, 100, ROTOR_RADIUS_M)).toBe(0); + expect(overlapShare(-300, 100, ROTOR_RADIUS_M)).toBe(0); + }); + + it('shares half the rotor when two equal circles meet at the centre line', () => { + expect(overlapShare(0, ROTOR_RADIUS_M, ROTOR_RADIUS_M)).toBe(1); + const halfOffset = ROTOR_RADIUS_M * 0.8079; + expect(overlapShare(halfOffset, ROTOR_RADIUS_M, ROTOR_RADIUS_M)).toBeCloseTo(0.5, 3); + }); + + it('shrinks smoothly as the offset grows', () => { + const shares = [0, 40, 80, 120, 160].map((offset) => overlapShare(offset, 120, ROTOR_RADIUS_M)); + shares.slice(1).forEach((share, index) => expect(share).toBeLessThanOrEqual(shares[index])); + }); +}); + +describe('wakeDeficits', () => { + it('slows only the turbine downwind of the other', () => { + const [lead, trailing] = wakeDeficits([LEAD, behind(7)], THRUST_AT_8, 270); + expect(lead).toBe(0); + expect(trailing).toBeCloseTo(centrelineDeficit(THRUST_AT_8, 7), 9); + }); + + it('blows toward +x from 270 and toward −x from 90', () => { + expect(wakeDeficits([LEAD, behind(7)], THRUST_AT_8, 90)[0]).toBeCloseTo( + centrelineDeficit(THRUST_AT_8, 7), + 9, + ); + }); + + it('turns a few degrees of veer into a small sideways shift', () => { + const straight = wakeDeficits([LEAD, behind(7)], THRUST_AT_8, 270)[1]; + const veered = wakeDeficits([LEAD, behind(7)], THRUST_AT_8, 276)[1]; + expect(veered).toBeGreaterThan(0); + expect(veered).toBeLessThan(straight); + expect(wakeDeficits([LEAD, behind(7, 110)], THRUST_AT_8, 276)[1]).toBeGreaterThan(veered); + }); + + it('adds wakes from two turbines as the root of the sum of squares', () => { + const [, , last] = wakeDeficits([LEAD, behind(7), behind(14)], THRUST_AT_8, 270); + const fromLead = centrelineDeficit(THRUST_AT_8, 14); + const fromMiddle = centrelineDeficit(THRUST_AT_8, 7); + expect(last).toBeCloseTo(Math.hypot(fromLead, fromMiddle), 9); + }); + + it('keeps the upwind row free in the farm', () => { + const deficits = wakeDeficits(farmLayout(7), THRUST_AT_8, 270); + expect(deficits).toHaveLength(TURBINE_COUNT); + expect(deficits[HERO_SITE]).toBe(0); + expect(Math.max(...deficits)).toBeCloseTo(0.056, 3); + }); +}); + +describe('farm power', () => { + it('loses about 7.6, 4.9 and 3.5 % to wakes at 8 m/s from 270', () => { + expect(wakeLoss(8, 270, 5)).toBeCloseTo(0.076, 3); + expect(wakeLoss(8, 270, 7)).toBeCloseTo(0.049, 3); + expect(wakeLoss(8, 270, 9)).toBeCloseTo(0.035, 3); + }); + + it('sums the waked turbines below 27 free ones', () => { + const farm = farmPowerKw(8, 270, 7); + expect(farm).toBeLessThan(TURBINE_COUNT * turbinePowerKw(8)); + expect(farm).toBeCloseTo(TURBINE_COUNT * turbinePowerKw(8) * (1 - wakeLoss(8, 270, 7)), 6); + }); + + it('makes nothing below cut-in or from cut-out up', () => { + expect(farmPowerKw(2, 270, 7)).toBe(0); + expect(farmPowerKw(25, 270, 7)).toBe(0); + expect(wakeLoss(2, 270, 7)).toBe(0); + expect(wakeLoss(25, 270, 7)).toBe(0); + }); + + it('barely loses anything at full power', () => { + expect(wakeLoss(15, 270, 7)).toBeLessThan(0.001); + }); +}); + +describe('plumeLengthD', () => { + it('draws the visible plume about 8.6 D at 12 m/s and 4.1 D at 15 m/s', () => { + expect(plumeLengthD(thrustCoefficient(12))).toBeCloseTo(8.6, 1); + expect(plumeLengthD(thrustCoefficient(15))).toBeCloseTo(4.09, 2); + }); + + it('caps the 21.7 D plume at 8 m/s to the drawing limit', () => { + expect(plumeLengthD(thrustCoefficient(8))).toBe(MAX_PLUME_D); + }); + + it('draws no plume without thrust', () => { + expect(plumeLengthD(0)).toBe(0); + }); +}); diff --git a/explainers/wind-farm/src/model/wakes.ts b/explainers/wind-farm/src/model/wakes.ts new file mode 100644 index 00000000..1dff33e8 --- /dev/null +++ b/explainers/wind-farm/src/model/wakes.ts @@ -0,0 +1,108 @@ +import { clamp, toRadians } from '@core/math'; +import type { FarmSite, SpacingD } from '../ids'; +import { + MAX_PLUME_D, + PLUME_VISIBLE_DEFICIT, + ROTOR_RADIUS_M, + TURBINE_COUNT, + WAKE_DECAY, +} from './constants'; +import { farmLayout } from './layout'; +import { isGenerating, thrustCoefficient, turbinePowerKw } from './power'; + +interface DownwindOffset { + along: number; + across: number; +} + +export function initialDeficit(ct: number): number { + return 1 - Math.sqrt(1 - ct); +} + +export function axialInduction(ct: number): number { + return initialDeficit(ct) / 2; +} + +function expansion(xOverD: number): number { + return 1 + 2 * WAKE_DECAY * xOverD; +} + +export function centrelineDeficit(ct: number, xOverD: number): number { + return initialDeficit(ct) / expansion(xOverD) ** 2; +} + +function lensAngle(offset: number, near: number, far: number): number { + return Math.acos(clamp((offset ** 2 + near ** 2 - far ** 2) / (2 * offset * near), -1, 1)); +} + +function lensArea(offset: number, radiusA: number, radiusB: number): number { + const kite = Math.sqrt( + Math.max( + 0, + (-offset + radiusA + radiusB) * + (offset + radiusA - radiusB) * + (offset - radiusA + radiusB) * + (offset + radiusA + radiusB), + ), + ); + return ( + radiusA ** 2 * lensAngle(offset, radiusA, radiusB) + + radiusB ** 2 * lensAngle(offset, radiusB, radiusA) - + kite / 2 + ); +} + +export function overlapShare(offset: number, wakeRadius: number, rotorRadius: number): number { + const distance = Math.abs(offset); + if (distance >= wakeRadius + rotorRadius) return 0; + if (distance <= Math.abs(wakeRadius - rotorRadius)) { + return Math.min(1, (wakeRadius / rotorRadius) ** 2); + } + return lensArea(distance, wakeRadius, rotorRadius) / (Math.PI * rotorRadius ** 2); +} + +function downwindOffset(from: FarmSite, to: FarmSite, fromDeg: number): DownwindOffset { + const bearing = toRadians(fromDeg); + const towardX = -Math.sin(bearing); + const towardZ = Math.cos(bearing); + const dx = to.x - from.x; + const dz = to.z - from.z; + return { along: dx * towardX + dz * towardZ, across: dx * towardZ - dz * towardX }; +} + +function wakeDeficitAt(offset: DownwindOffset, strength: number): number { + const wakeRadius = ROTOR_RADIUS_M + WAKE_DECAY * offset.along; + return ( + strength * + (ROTOR_RADIUS_M / wakeRadius) ** 2 * + overlapShare(offset.across, wakeRadius, ROTOR_RADIUS_M) + ); +} + +export function wakeDeficits(sites: readonly FarmSite[], ct: number, fromDeg: number): number[] { + const strength = initialDeficit(ct); + return sites.map((site) => { + const squares = sites.reduce((sum, upwind) => { + const offset = downwindOffset(upwind, site, fromDeg); + return offset.along > 0 ? sum + wakeDeficitAt(offset, strength) ** 2 : sum; + }, 0); + return Math.sqrt(squares); + }); +} + +export function farmPowerKw(wind: number, fromDeg: number, spacing: SpacingD): number { + if (!isGenerating(wind)) return 0; + const deficits = wakeDeficits(farmLayout(spacing), thrustCoefficient(wind), fromDeg); + return deficits.reduce((sum, deficit) => sum + turbinePowerKw(wind * (1 - deficit)), 0); +} + +export function wakeLoss(wind: number, fromDeg: number, spacing: SpacingD): number { + const freeKw = turbinePowerKw(wind); + if (freeKw === 0) return 0; + return Math.max(0, 1 - farmPowerKw(wind, fromDeg, spacing) / (TURBINE_COUNT * freeKw)); +} + +export function plumeLengthD(ct: number): number { + const visibleExpansion = Math.sqrt(initialDeficit(ct) / PLUME_VISIBLE_DEFICIT); + return clamp((visibleExpansion - 1) / (2 * WAKE_DECAY), 0, MAX_PLUME_D); +} From 589d506e5ed0d0f4472adb24f52165b5b238e3c8 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:17:14 +0200 Subject: [PATCH 009/132] feat(wind-farm): add the day timeline --- explainers/wind-farm/src/timeline.dom.test.ts | 56 ++++++++++++++++ explainers/wind-farm/src/timeline.ts | 65 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 explainers/wind-farm/src/timeline.dom.test.ts create mode 100644 explainers/wind-farm/src/timeline.ts diff --git a/explainers/wind-farm/src/timeline.dom.test.ts b/explainers/wind-farm/src/timeline.dom.test.ts new file mode 100644 index 00000000..31952a7c --- /dev/null +++ b/explainers/wind-farm/src/timeline.dom.test.ts @@ -0,0 +1,56 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { initI18n } from '@core/i18n'; +import en from '../locales/en.json'; +import { PHASE_IDS } from './ids'; +import type { PhaseId } from './ids'; +import { CYCLE_MINUTES, DEFAULT_SPEED, PHASE_RANGES, SPEED_RANGE, rate } from './model'; +import { PHASE_TONES } from './theme'; +import { + WIND_FARM_TIMELINE, + describePhase, + describeSpeed, + formatPhase, + formatSpeed, +} from './timeline'; + +describe('wind farm timeline', () => { + beforeAll(() => initI18n({ en: () => Promise.resolve(en) })); + + it('loops one day of minutes through the five parts of the day', () => { + const { phases, cycle, loop } = WIND_FARM_TIMELINE; + expect(cycle).toBe(CYCLE_MINUTES); + expect(loop).toBe(true); + expect(phases.map((phase) => phase.id)).toEqual([...PHASE_IDS]); + phases.forEach((phase) => { + const [start, end] = PHASE_RANGES[phase.id as PhaseId]; + expect(phase).toMatchObject({ start, end, tone: PHASE_TONES[phase.id as PhaseId] }); + expect(phase.labelKey).toBe(`timeline.phase.${phase.id}`); + expect(phase.jumpLabelKey).toBe(`timeline.jump.${phase.id}`); + }); + expect(phases[0].start).toBe(0); + expect(phases.at(-1)?.end).toBe(cycle); + phases.slice(1).forEach((phase, index) => expect(phase.start).toBe(phases[index].end)); + }); + + it('steps by five minutes and nudges by a quarter hour or an hour', () => { + expect(WIND_FARM_TIMELINE.step).toBe(5); + expect(WIND_FARM_TIMELINE.nudge).toEqual({ fine: 15, coarse: 60 }); + }); + + it('shows the clock time and names the part of the day', () => { + expect(formatPhase(0)).toContain('00:00'); + expect(formatPhase(1148)).toContain('19:08'); + expect(describePhase(720)).toContain('12:00'); + expect(describePhase(720)).toContain(en.timeline.phase.afternoon); + expect(describePhase(1000)).toContain(en.timeline.phase.storm); + }); + + it('plays the day at hours per reader minute', () => { + const { speed } = WIND_FARM_TIMELINE; + expect(speed).toMatchObject({ min: 1, max: 60, step: 1 }); + expect(SPEED_RANGE.default).toBe(DEFAULT_SPEED); + expect(WIND_FARM_TIMELINE.rate(DEFAULT_SPEED)).toBe(rate(DEFAULT_SPEED)); + expect(formatSpeed(DEFAULT_SPEED)).toContain('12'); + expect(describeSpeed(DEFAULT_SPEED)).toContain('12'); + }); +}); diff --git a/explainers/wind-farm/src/timeline.ts b/explainers/wind-farm/src/timeline.ts new file mode 100644 index 00000000..a52b41af --- /dev/null +++ b/explainers/wind-farm/src/timeline.ts @@ -0,0 +1,65 @@ +import type { Phase, Timeline } from '@core/explainer'; +import { formatNumber } from '@core/format'; +import { t } from '@core/i18n'; +import { PHASE_IDS } from './ids'; +import type { PhaseId } from './ids'; +import { CYCLE_MINUTES, PHASE_RANGES, SPEED_RANGE, clockOf, phaseOf, rate } from './model'; +import { PHASE_TONES } from './theme'; + +const SCRUBBER_STEP_MINUTES = 5; +const FINE_STEP_MINUTES = 15; +const COARSE_STEP_MINUTES = 60; + +function phaseKey(id: PhaseId): string { + return `timeline.phase.${id}`; +} + +function jumpKey(id: PhaseId): string { + return `timeline.jump.${id}`; +} + +export function formatPhase(minute: number): string { + return t('timeline.value', { time: clockOf(minute) }); +} + +export function describePhase(minute: number): string { + return t('timeline.during', { time: clockOf(minute), phase: t(phaseKey(phaseOf(minute))) }); +} + +export function formatSpeed(speed: number): string { + return t('timeline.speedValue', { value: formatNumber(speed) }); +} + +export function describeSpeed(speed: number): string { + return t('timeline.speedFormat', { value: formatNumber(speed) }); +} + +const DAY_PHASES: readonly Phase[] = PHASE_IDS.map((id) => ({ + id, + start: PHASE_RANGES[id][0], + end: PHASE_RANGES[id][1], + labelKey: phaseKey(id), + jumpLabelKey: jumpKey(id), + tone: PHASE_TONES[id], +})); + +export const WIND_FARM_TIMELINE: Timeline = { + cycle: CYCLE_MINUTES, + loop: true, + step: SCRUBBER_STEP_MINUTES, + nudge: { fine: FINE_STEP_MINUTES, coarse: COARSE_STEP_MINUTES }, + labelKey: 'timeline.label', + phasesLabelKey: 'timeline.phases', + formatPhase, + describePhase, + rate, + phases: DAY_PHASES, + speed: { + min: SPEED_RANGE.min, + max: SPEED_RANGE.max, + step: SPEED_RANGE.step, + labelKey: 'timeline.speed', + format: formatSpeed, + describe: describeSpeed, + }, +}; From 1669baea0d29031507abfe0ffe4bc46cb57c7a77 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:17:28 +0200 Subject: [PATCH 010/132] feat(wind-farm): add the chapter presets and the store --- .../wind-farm/src/state/presets.test.ts | 46 ++++++++++ explainers/wind-farm/src/state/presets.ts | 91 +++++++++++++++++++ explainers/wind-farm/src/state/store.test.ts | 79 ++++++++++++++++ explainers/wind-farm/src/state/store.ts | 84 +++++++++++++++++ 4 files changed, 300 insertions(+) create mode 100644 explainers/wind-farm/src/state/presets.test.ts create mode 100644 explainers/wind-farm/src/state/presets.ts create mode 100644 explainers/wind-farm/src/state/store.test.ts create mode 100644 explainers/wind-farm/src/state/store.ts diff --git a/explainers/wind-farm/src/state/presets.test.ts b/explainers/wind-farm/src/state/presets.test.ts new file mode 100644 index 00000000..ac0614a1 --- /dev/null +++ b/explainers/wind-farm/src/state/presets.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; +import { PRESET_IDS, SCENE_PARTS } from '../ids'; +import { CYCLE_MINUTES, DEFAULT_SPEED, PHASE_RANGES } from '../model'; +import { sceneOf } from './derived'; +import { PRESETS } from './presets'; + +describe('wind farm presets', () => { + it('defines one preset per chapter at the default speed', () => { + expect(Object.keys(PRESETS)).toEqual([...PRESET_IDS]); + PRESET_IDS.forEach((id) => expect(PRESETS[id].speed).toBe(DEFAULT_SPEED)); + }); + + it('labels and highlights only parts of the scene the chapter shows', () => { + PRESET_IDS.forEach((id) => { + const parts = SCENE_PARTS[sceneOf(id)]; + [...PRESETS[id].labels, ...PRESETS[id].highlight].forEach((part) => + expect(parts, `${id} ${part}`).toContain(part), + ); + }); + }); + + it('shows the farm in chapters 1, 5 and 6 and the turbine in chapters 2, 3 and 4', () => { + expect(PRESET_IDS.map(sceneOf)).toEqual([ + 'farm', + 'turbine', + 'turbine', + 'turbine', + 'farm', + 'farm', + ]); + }); + + it('opens the nacelle only in its own chapter', () => { + PRESET_IDS.forEach((id) => expect(PRESETS[id].view.cutaway).toBe(id === 'nacelle')); + }); + + it('starts every chapter inside the day and follows the day wind', () => { + PRESET_IDS.forEach((id) => { + const seek = PRESETS[id].startAt ?? PRESETS[id].pauseAt; + expect(seek).toBeGreaterThanOrEqual(0); + expect(seek).toBeLessThan(CYCLE_MINUTES); + expect(PRESETS[id].start?.windOverride).toBeNull(); + }); + expect(PRESETS.wakes.pauseAt).toBeGreaterThanOrEqual(PHASE_RANGES.morning[0]); + }); +}); diff --git a/explainers/wind-farm/src/state/presets.ts b/explainers/wind-farm/src/state/presets.ts new file mode 100644 index 00000000..dff732fa --- /dev/null +++ b/explainers/wind-farm/src/state/presets.ts @@ -0,0 +1,91 @@ +import type { ScenePreset } from '@core/scene/presetBinder'; +import type { CameraView, PartId, PresetId, SpacingD, ViewOptions } from '../ids'; +import { DEFAULT_SPACING_D, DEFAULT_SPEED } from '../model'; + +export interface ChapterControls { + spacing: SpacingD; + windOverride: number | null; +} + +export interface Preset extends ScenePreset { + view: Partial; + start?: Partial; +} + +const NOON = 720; +const EARLY_AFTERNOON = 780; +const DAWN = 360; +const MID_MORNING = 540; + +const FOLLOW_THE_DAY: Partial = { windOverride: null }; +const DEFAULT_FARM: Partial = { spacing: DEFAULT_SPACING_D, windOverride: null }; + +const NACELLE_INTERIOR: readonly PartId[] = [ + 'hub', + 'pitchCylinders', + 'mainBearing', + 'mainShaft', + 'gearbox', + 'brakeDisc', + 'generator', + 'converter', + 'yawDrives', + 'cooler', +]; + +export const PRESETS: Record = { + farm: { + camera: 'farmAerial', + speed: DEFAULT_SPEED, + view: { cutaway: false, streamlines: false, wakes: false, cables: false }, + startAt: NOON, + start: DEFAULT_FARM, + labels: ['farmTurbines', 'prevailingWind', 'spacingMarker', 'accessRoads', 'substation'], + highlight: [], + }, + tower: { + camera: 'turbineTall', + speed: DEFAULT_SPEED, + view: { cutaway: false, streamlines: true, wakes: false, cables: false }, + startAt: NOON, + start: FOLLOW_THE_DAY, + labels: ['blades', 'hub', 'nacelle', 'tower', 'foundation', 'sweptArea', 'transformer'], + highlight: ['blades', 'hub', 'sweptArea'], + }, + nacelle: { + camera: 'nacelleCutaway', + speed: DEFAULT_SPEED, + view: { cutaway: true, streamlines: false, wakes: false, cables: false }, + startAt: EARLY_AFTERNOON, + start: FOLLOW_THE_DAY, + labels: NACELLE_INTERIOR, + highlight: [...NACELLE_INTERIOR, 'bedplate'], + }, + curve: { + camera: 'rotorQuarter', + speed: DEFAULT_SPEED, + view: { cutaway: false, streamlines: true, wakes: true, cables: false }, + startAt: DAWN, + start: FOLLOW_THE_DAY, + labels: ['blades', 'hub', 'nacelle', 'streamlinesGroup', 'heroWake'], + highlight: ['blades', 'hub', 'streamlinesGroup', 'heroWake'], + }, + wakes: { + camera: 'wakeStreaks', + speed: DEFAULT_SPEED, + view: { cutaway: false, streamlines: true, wakes: true, cables: false }, + pauseAt: MID_MORNING, + start: DEFAULT_FARM, + labels: ['wakePlumes', 'farmTurbines', 'spacingMarker', 'prevailingWind', 'shearProfile'], + highlight: ['wakePlumes', 'spacingMarker', 'shearProfile'], + }, + grid: { + camera: 'gridSubstation', + speed: DEFAULT_SPEED, + view: { cutaway: false, streamlines: false, wakes: false, cables: true }, + startAt: NOON, + start: DEFAULT_FARM, + labels: ['collectorCables', 'substation', 'gridLine', 'farmTurbines', 'accessRoads'], + highlight: ['collectorCables', 'substation', 'gridLine'], + }, +}; diff --git a/explainers/wind-farm/src/state/store.test.ts b/explainers/wind-farm/src/state/store.test.ts new file mode 100644 index 00000000..16b5b4c3 --- /dev/null +++ b/explainers/wind-farm/src/state/store.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_SPACING_D, PHASE_RANGES } from '../model'; +import { + CHAPTER_CONTROL_DEFAULTS, + DEFAULT_SITE_WIND, + DEFAULT_VIEW, + WIND_OVERRIDE_RANGE, + createWindFarmStore, + snapToRange, +} from './store'; + +describe('wind farm store', () => { + it('starts on the farm chapter at noon on a typical site with every view shown', () => { + const state = createWindFarmStore().getState(); + expect(state.preset).toBe('farm'); + expect(state.siteWind).toBe(DEFAULT_SITE_WIND); + expect(state.spacing).toBe(DEFAULT_SPACING_D); + expect(state.windOverride).toBeNull(); + expect(DEFAULT_VIEW).toEqual({ + streamlines: true, + wakes: true, + cables: true, + labels: true, + cutaway: false, + }); + }); + + it('snaps the wind override to half metres per second inside its range', () => { + const store = createWindFarmStore(); + store.getState().setWindOverride(7.3); + expect(store.getState().windOverride).toBe(7.5); + store.getState().setWindOverride(40); + expect(store.getState().windOverride).toBe(WIND_OVERRIDE_RANGE.max); + store.getState().setWindOverride(-2); + expect(store.getState().windOverride).toBe(WIND_OVERRIDE_RANGE.min); + store.getState().setWindOverride(null); + expect(store.getState().windOverride).toBeNull(); + expect(snapToRange(12.26, WIND_OVERRIDE_RANGE)).toBe(12.5); + }); + + it('resets the chapter controls on a chapter change and keeps them inside a chapter', () => { + const store = createWindFarmStore(); + store.getState().applyPreset('wakes'); + store.getState().setSpacing(9); + store.getState().setWindOverride(20); + store.getState().applyPreset('wakes'); + expect(store.getState().spacing).toBe(9); + expect(store.getState().windOverride).toBe(20); + store.getState().applyPreset('grid'); + expect(store.getState().spacing).toBe(CHAPTER_CONTROL_DEFAULTS.spacing); + expect(store.getState().windOverride).toBeNull(); + }); + + it('keeps the site wind and the labels toggle across chapters', () => { + const store = createWindFarmStore(); + store.getState().setSiteWind('windy'); + store.getState().setView({ labels: false }); + store.getState().applyPreset('curve'); + expect(store.getState().siteWind).toBe('windy'); + expect(store.getState().view.labels).toBe(false); + }); + + it('opens the nacelle in its chapter and closes it on leaving', () => { + const store = createWindFarmStore(); + store.getState().applyPreset('nacelle'); + expect(store.getState().view.cutaway).toBe(true); + store.getState().applyPreset('tower'); + expect(store.getState().view.cutaway).toBe(false); + }); + + it('pauses the wakes chapter in the morning breeze', () => { + const store = createWindFarmStore(); + store.getState().applyPreset('wakes'); + const { phase, playing } = store.getState(); + expect(playing).toBe(false); + expect(phase).toBeGreaterThanOrEqual(PHASE_RANGES.morning[0]); + expect(phase).toBeLessThan(PHASE_RANGES.morning[1]); + }); +}); diff --git a/explainers/wind-farm/src/state/store.ts b/explainers/wind-farm/src/state/store.ts new file mode 100644 index 00000000..08527063 --- /dev/null +++ b/explainers/wind-farm/src/state/store.ts @@ -0,0 +1,84 @@ +import type { ExplainerStore, Playback, PlaybackState } from '@core/explainer'; +import { clamp } from '@core/math'; +import { createExplainerStore } from '@core/store'; +import type { PresetId, SiteWindId, SpacingD, ViewOptions } from '../ids'; +import { DEFAULT_SPACING_D, SPEED_RANGE } from '../model'; +import { WIND_FARM_TIMELINE } from '../timeline'; +import { PRESETS } from './presets'; +import type { ChapterControls, Preset } from './presets'; + +export type ViewState = { [Key in keyof ViewOptions]: ViewOptions[Key] }; + +export interface WindFarmFields extends ChapterControls { + siteWind: SiteWindId; + view: ViewState; + preset: PresetId; +} + +export interface WindFarmOwnActions { + setSiteWind(siteWind: SiteWindId): void; + setSpacing(spacing: SpacingD): void; + setWindOverride(windOverride: number | null): void; +} + +export type WindFarmState = PlaybackState & WindFarmFields; +export type WindFarmStoreState = Playback & WindFarmFields & WindFarmOwnActions; +export type WindFarmStore = ExplainerStore; + +export interface SteppedRange { + min: number; + max: number; + step: number; +} + +export const WIND_OVERRIDE_RANGE = { min: 0, max: 30, step: 0.5 } as const; + +export const DEFAULT_VIEW: ViewState = { + streamlines: true, + wakes: true, + cables: true, + labels: true, + cutaway: false, +}; +export const DEFAULT_SITE_WIND: SiteWindId = 'typical'; + +export const CHAPTER_CONTROL_DEFAULTS: ChapterControls = { + spacing: DEFAULT_SPACING_D, + windOverride: null, +}; + +const START_PRESET: PresetId = 'farm'; +const START_SPEED = PRESETS[START_PRESET].speed ?? SPEED_RANGE.default; + +export function snapToRange(value: number, { min, max, step }: SteppedRange): number { + return clamp(min + Math.round((value - min) / step) * step, min, max); +} + +function snapOverride(windOverride: number | null): number | null { + return windOverride === null ? null : snapToRange(windOverride, WIND_OVERRIDE_RANGE); +} + +function presetFields(preset: Preset, state: WindFarmFields): Partial { + if (PRESETS[state.preset] === preset) return {}; + return { ...CHAPTER_CONTROL_DEFAULTS, ...preset.start }; +} + +export function createWindFarmStore(overrides: Partial = {}): WindFarmStore { + return createExplainerStore( + { + timeline: WIND_FARM_TIMELINE, + presets: PRESETS, + defaults: { preset: START_PRESET, speed: START_SPEED, view: DEFAULT_VIEW }, + extend: (set) => ({ + ...CHAPTER_CONTROL_DEFAULTS, + ...PRESETS[START_PRESET].start, + siteWind: DEFAULT_SITE_WIND, + setSiteWind: (siteWind) => set({ siteWind }), + setSpacing: (spacing) => set({ spacing }), + setWindOverride: (windOverride) => set({ windOverride: snapOverride(windOverride) }), + }), + presetState: presetFields, + }, + overrides, + ); +} From 559ccc7e05d7b6bf4dfc64983a7425484a21979f Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:17:29 +0200 Subject: [PATCH 011/132] feat(wind-farm): derive the live reading and the assembly state --- .../wind-farm/src/state/derived.test.ts | 106 ++++++++++++++ explainers/wind-farm/src/state/derived.ts | 137 ++++++++++++++++++ explainers/wind-farm/src/state/index.ts | 3 + 3 files changed, 246 insertions(+) create mode 100644 explainers/wind-farm/src/state/derived.test.ts create mode 100644 explainers/wind-farm/src/state/derived.ts create mode 100644 explainers/wind-farm/src/state/index.ts diff --git a/explainers/wind-farm/src/state/derived.test.ts b/explainers/wind-farm/src/state/derived.test.ts new file mode 100644 index 00000000..c99576b6 --- /dev/null +++ b/explainers/wind-farm/src/state/derived.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { WIND_PRESETS, dayWind, farmLayout, windFromDeg } from '../model'; +import { + createAssemblyState, + farmSitesOf, + liveReading, + liveWind, + sceneOf, + writeAssemblyState, +} from './derived'; +import type { AssemblySource } from './derived'; +import { DEFAULT_VIEW } from './store'; + +const NIGHT = 120; +const NOON = 720; +const STORM_CORE_MINUTE = 1050; + +function source(overrides: Partial = {}): AssemblySource { + return { + phase: NOON, + playing: true, + preset: 'farm', + siteWind: 'typical', + windOverride: null, + spacing: 7, + view: { ...DEFAULT_VIEW }, + ...overrides, + }; +} + +describe('live wind', () => { + it('follows the day wind of the site until a wind is set', () => { + expect(liveWind(source())).toBeCloseTo(dayWind(NOON, 'typical')); + expect(liveWind(source({ siteWind: 'windy' }))).toBeCloseTo(dayWind(NOON, 'windy')); + expect(liveWind(source({ windOverride: 8 }))).toBe(8); + }); + + it('reuses the reading while its inputs stay the same', () => { + expect(liveReading(source())).toBe(liveReading(source({ playing: false }))); + expect(liveReading(source())).not.toBe(liveReading(source({ spacing: 5 }))); + }); + + it('parks the turbines above cut-out whatever the clock says', () => { + const reading = liveReading(source({ windOverride: WIND_PRESETS.cutOut })); + expect(reading.operating.state).toBe('parked'); + expect(reading.heroKw).toBe(0); + expect(reading.farmKw).toBe(0); + expect(reading.thrust).toBe(0); + }); +}); + +describe('assembly state', () => { + it('shows the scene of the chapter', () => { + expect(sceneOf('farm')).toBe('farm'); + expect(sceneOf('nacelle')).toBe('turbine'); + expect(createAssemblyState(source({ preset: 'curve' })).scene).toBe('turbine'); + }); + + it('runs at full power at noon with the rotors facing the wind', () => { + const state = createAssemblyState(source()); + expect(state.rotor.state).toBe('full'); + expect(state.rotor.powerShare).toBeCloseTo(1); + expect(state.rotor.yawDeg).toBeCloseTo(windFromDeg(NOON)); + expect(state.wind.induction).toBeGreaterThan(0); + expect(state.farm.outputShare).toBeGreaterThan(0.9); + expect(state.farm.outputShare).toBeLessThanOrEqual(1); + expect(state.farm.sites).toEqual(farmLayout(7)); + expect(state.farm.sites).toBe(farmSitesOf(7)); + expect(state.farm.deficits).toHaveLength(state.farm.sites.length); + expect(state.farm.plumeLengthD).toBeGreaterThan(0); + expect(state.farm.plumeStrength).toBeGreaterThan(0); + }); + + it('waits below cut-in at night with no plumes and no output', () => { + const state = createAssemblyState(source({ phase: NIGHT })); + expect(state.rotor.state).toBe('idle'); + expect(state.rotor.powerShare).toBe(0); + expect(state.farm.outputShare).toBe(0); + expect(state.farm.plumeLengthD).toBe(0); + expect(state.farm.plumeStrength).toBe(0); + }); + + it('parks and brakes the rotor in the storm', () => { + const state = createAssemblyState(source({ phase: STORM_CORE_MINUTE })); + expect(state.rotor.state).toBe('parked'); + expect(state.rotor.braked).toBe(true); + expect(state.rotor.rpm).toBe(0); + expect(state.wind.induction).toBe(0); + expect(state.farm.outputShare).toBe(0); + }); + + it('rewrites the same objects so the bindings can double buffer', () => { + const target = createAssemblyState(source()); + const { wind, rotor, farm, view } = target; + const next = writeAssemblyState( + target, + source({ preset: 'tower', spacing: 9, view: { ...DEFAULT_VIEW, cutaway: true } }), + ); + expect(next).toBe(target); + expect([next.wind, next.rotor, next.farm, next.view]).toEqual([wind, rotor, farm, view]); + expect(next.wind).toBe(wind); + expect(next.scene).toBe('turbine'); + expect(next.farm.spacing).toBe(9); + expect(next.view.cutaway).toBe(true); + }); +}); diff --git a/explainers/wind-farm/src/state/derived.ts b/explainers/wind-farm/src/state/derived.ts new file mode 100644 index 00000000..d8bc64d9 --- /dev/null +++ b/explainers/wind-farm/src/state/derived.ts @@ -0,0 +1,137 @@ +import type { AssemblyState, FarmSite, PresetId, SceneId, SpacingD } from '../ids'; +import { PRESET_SCENES, SPACING_OPTIONS } from '../ids'; +import { + FARM_RATED_KW, + RATED_KW, + axialInduction, + dayWind, + farmLayout, + farmPowerKw, + initialDeficit, + operatingAt, + operatingAtWind, + plumeLengthD, + thrustCoefficient, + turbinePowerKw, + wakeDeficits, + windFromDeg, +} from '../model'; +import type { OperatingReading } from '../model'; +import type { WindFarmState } from './store'; + +export type LiveSource = Pick; + +export type AssemblySource = LiveSource & Pick; + +export interface LiveReading { + wind: number; + fromDeg: number; + operating: OperatingReading; + thrust: number; + heroKw: number; + farmKw: number; + deficits: readonly number[]; +} + +const FARM_SITES = new Map( + SPACING_OPTIONS.map((spacing) => [spacing, farmLayout(spacing)]), +); + +export function farmSitesOf(spacing: SpacingD): readonly FarmSite[] { + return FARM_SITES.get(spacing) ?? farmLayout(spacing); +} + +export function sceneOf(preset: PresetId): SceneId { + return PRESET_SCENES[preset]; +} + +export function liveWind({ phase, siteWind, windOverride }: LiveSource): number { + return windOverride ?? dayWind(phase, siteWind); +} + +function operatingOf(source: LiveSource, wind: number): OperatingReading { + return source.windOverride === null + ? operatingAt(source.phase, source.siteWind) + : operatingAtWind(wind); +} + +function readingOf(source: LiveSource): LiveReading { + const wind = liveWind(source); + const fromDeg = windFromDeg(source.phase); + const operating = operatingOf(source, wind); + const thrust = operating.producing ? thrustCoefficient(wind) : 0; + return { + wind, + fromDeg, + operating, + thrust, + heroKw: operating.producing ? turbinePowerKw(wind) : 0, + farmKw: operating.producing ? farmPowerKw(wind, fromDeg, source.spacing) : 0, + deficits: wakeDeficits(farmSitesOf(source.spacing), thrust, fromDeg), + }; +} + +let lastSource: LiveSource | null = null; +let lastReading: LiveReading | null = null; + +function sameSource(a: LiveSource, b: LiveSource): boolean { + return ( + a.phase === b.phase && + a.siteWind === b.siteWind && + a.windOverride === b.windOverride && + a.spacing === b.spacing + ); +} + +export function liveReading(source: LiveSource): LiveReading { + if (lastReading && lastSource && sameSource(lastSource, source)) return lastReading; + const { phase, siteWind, windOverride, spacing } = source; + lastSource = { phase, siteWind, windOverride, spacing }; + lastReading = readingOf(source); + return lastReading; +} + +export function writeAssemblyState(target: AssemblyState, source: AssemblySource): AssemblyState { + const reading = liveReading(source); + const { operating } = reading; + target.scene = sceneOf(source.preset); + target.phase = source.phase; + target.playing = source.playing; + target.wind.speed = reading.wind; + target.wind.fromDeg = reading.fromDeg; + target.wind.induction = axialInduction(reading.thrust); + target.rotor.rpm = operating.rpm; + target.rotor.pitchDeg = operating.pitchDeg; + target.rotor.yawDeg = reading.fromDeg; + target.rotor.state = operating.state; + target.rotor.braked = operating.braked; + target.rotor.powerShare = reading.heroKw / RATED_KW; + target.farm.spacing = source.spacing; + target.farm.sites = farmSitesOf(source.spacing); + target.farm.deficits = reading.deficits; + target.farm.plumeLengthD = plumeLengthD(reading.thrust); + target.farm.plumeStrength = initialDeficit(reading.thrust); + target.farm.outputShare = reading.farmKw / FARM_RATED_KW; + Object.assign(target.view, source.view); + return target; +} + +export function createAssemblyState(source: AssemblySource): AssemblyState { + const target: AssemblyState = { + scene: sceneOf(source.preset), + phase: source.phase, + playing: source.playing, + wind: { speed: 0, fromDeg: 0, induction: 0 }, + rotor: { rpm: 0, pitchDeg: 0, yawDeg: 0, state: 'idle', braked: false, powerShare: 0 }, + farm: { + spacing: source.spacing, + sites: farmSitesOf(source.spacing), + deficits: [], + plumeLengthD: 0, + plumeStrength: 0, + outputShare: 0, + }, + view: { ...source.view }, + }; + return writeAssemblyState(target, source); +} diff --git a/explainers/wind-farm/src/state/index.ts b/explainers/wind-farm/src/state/index.ts new file mode 100644 index 00000000..029bc932 --- /dev/null +++ b/explainers/wind-farm/src/state/index.ts @@ -0,0 +1,3 @@ +export * from './derived'; +export * from './presets'; +export * from './store'; From 2f9a0eb46ec3ffc951a5f7281f0dfed70367be08 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:17:32 +0200 Subject: [PATCH 012/132] feat(wind-farm): add the dock controls, chapter actions and styles --- explainers/wind-farm/src/style.css | 95 +++++++++++++++++++++ explainers/wind-farm/src/ui/actions.test.ts | 66 ++++++++++++++ explainers/wind-farm/src/ui/actions.ts | 52 +++++++++++ explainers/wind-farm/src/ui/dock.ts | 45 ++++++++++ 4 files changed, 258 insertions(+) create mode 100644 explainers/wind-farm/src/style.css create mode 100644 explainers/wind-farm/src/ui/actions.test.ts create mode 100644 explainers/wind-farm/src/ui/actions.ts create mode 100644 explainers/wind-farm/src/ui/dock.ts diff --git a/explainers/wind-farm/src/style.css b/explainers/wind-farm/src/style.css new file mode 100644 index 00000000..145e468a --- /dev/null +++ b/explainers/wind-farm/src/style.css @@ -0,0 +1,95 @@ +:root { + --wind: #5fa8ff; + --power: #f2b134; + --cable: #ffb347; + --wake: #7f9fc0; + --cutaway: #c58b3a; + --screen: #1b1d20; +} + +.chapter .readouts { + --readout-min-width: 8rem; + margin-block: 16px; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px 16px; +} + +.chapter .readouts > div, +.chapter .readouts :is(dt, dd) { + min-width: 0; +} + +.chapter .readouts :is(dt, dd) { + margin: 0; + overflow-wrap: break-word; + hyphens: auto; +} + +.chapter .readouts dt { + color: var(--muted); + font-size: 0.8125rem; +} + +.chapter .readouts > div:last-child:nth-child(odd) { + grid-column: 1 / -1; +} + +.range-widget__header { + flex-wrap: wrap; +} + +.range-widget__header label { + min-width: 0; + overflow-wrap: break-word; +} + +.curve-widget { + margin: 4px 0 16px; +} + +.curve-widget canvas { + display: block; + width: 100%; + height: auto; + aspect-ratio: 2; + border-radius: var(--radius-small); + background: var(--screen); +} + +.curve-widget figcaption { + margin-top: 6px; + color: var(--muted); + font-size: 0.8125rem; +} + +.chip:is([data-action='windAt'], [data-action='siteWind']) { + --tone: var(--wind); +} + +.chip[data-action='windAt'][data-value='rated'] { + --tone: var(--power); +} + +.chip[data-action='spacing'] { + --tone: var(--wake); +} + +.chip[data-action='nacelle'] { + --tone: var(--cutaway); +} + +@media (pointer: coarse) { + .chip[data-action] { + height: 44px; + } +} + +@media (max-width: 480px) { + .chapter .readouts { + grid-template-columns: minmax(0, 1fr); + } + + .chapter .readouts > div:last-child:nth-child(odd) { + grid-column: auto; + } +} diff --git a/explainers/wind-farm/src/ui/actions.test.ts b/explainers/wind-farm/src/ui/actions.test.ts new file mode 100644 index 00000000..9f70754c --- /dev/null +++ b/explainers/wind-farm/src/ui/actions.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest'; +import { CHAPTER_ACTION_IDS, SITE_WIND_IDS, SPACING_CHOICE_IDS, WIND_PRESET_IDS } from '../ids'; +import { WIND_PRESETS } from '../model'; +import { createWindFarmStore } from '../state'; +import { CHAPTER_ACTIONS } from './actions'; + +const OFF_PRESET_WIND = 9.5; + +function action(id: string) { + const found = CHAPTER_ACTIONS[id]; + if (!found) throw new Error(`No action ${id}`); + return found; +} + +describe('chapter actions', () => { + it('covers every chapter action', () => { + expect(Object.keys(CHAPTER_ACTIONS)).toEqual([...CHAPTER_ACTION_IDS]); + }); + + it('switches the site wind', () => { + const store = createWindFarmStore(); + SITE_WIND_IDS.forEach((site) => { + action('siteWind').run(store.getState(), site); + expect(store.getState().siteWind).toBe(site); + expect(action('siteWind').current?.(store.getState())).toBe(site); + }); + }); + + it('switches the row spacing', () => { + const store = createWindFarmStore(); + SPACING_CHOICE_IDS.forEach((choice) => { + action('spacing').run(store.getState(), choice); + expect(store.getState().spacing).toBe(Number(choice)); + expect(action('spacing').current?.(store.getState())).toBe(choice); + }); + expect(() => action('spacing').run(store.getState(), '6')).toThrow(); + }); + + it('opens and closes the nacelle', () => { + const store = createWindFarmStore(); + action('nacelle').run(store.getState(), 'open'); + expect(store.getState().view.cutaway).toBe(true); + expect(action('nacelle').current?.(store.getState())).toBe('open'); + action('nacelle').run(store.getState(), 'closed'); + expect(store.getState().view.cutaway).toBe(false); + expect(action('nacelle').current?.(store.getState())).toBe('closed'); + }); + + it('pins the wind to a preset or hands it back to the day', () => { + const store = createWindFarmStore(); + WIND_PRESET_IDS.forEach((preset) => { + action('windAt').run(store.getState(), preset); + expect(store.getState().windOverride).toBe(WIND_PRESETS[preset]); + expect(action('windAt').current?.(store.getState())).toBe(preset); + }); + action('windAt').run(store.getState(), 'day'); + expect(store.getState().windOverride).toBeNull(); + expect(action('windAt').current?.(store.getState())).toBe('day'); + }); + + it('marks no wind chip for a wind that matches no preset', () => { + const store = createWindFarmStore(); + store.getState().setWindOverride(OFF_PRESET_WIND); + expect(action('windAt').current?.(store.getState())).toBe(''); + }); +}); diff --git a/explainers/wind-farm/src/ui/actions.ts b/explainers/wind-farm/src/ui/actions.ts new file mode 100644 index 00000000..cd1c6a19 --- /dev/null +++ b/explainers/wind-farm/src/ui/actions.ts @@ -0,0 +1,52 @@ +import type { ChapterAction } from '@core/explainer'; +import { parseOption } from '@core/ui/parse'; +import { + FOLLOW_DAY, + NACELLE_CHOICE_IDS, + SITE_WIND_IDS, + SPACING_OPTIONS, + WIND_AT_IDS, + WIND_PRESET_IDS, +} from '../ids'; +import type { NacelleChoiceId, SpacingD, WindAtId } from '../ids'; +import { WIND_PRESETS } from '../model'; +import type { WindFarmStoreState } from '../state'; + +const NOTHING_CURRENT = ''; +const OPEN: NacelleChoiceId = 'open'; +const CLOSED: NacelleChoiceId = 'closed'; + +function spacingOf(value: string): SpacingD { + const spacing = SPACING_OPTIONS.find((option) => String(option) === value); + if (spacing === undefined) throw new Error(`Unexpected spacing "${value}"`); + return spacing; +} + +function overrideOf(choice: WindAtId): number | null { + return choice === FOLLOW_DAY ? null : WIND_PRESETS[choice]; +} + +function windAtOf(override: number | null): WindAtId | typeof NOTHING_CURRENT { + if (override === null) return FOLLOW_DAY; + return WIND_PRESET_IDS.find((preset) => WIND_PRESETS[preset] === override) ?? NOTHING_CURRENT; +} + +export const CHAPTER_ACTIONS: Record> = { + siteWind: { + run: (state, value) => state.setSiteWind(parseOption(value, SITE_WIND_IDS)), + current: (state) => state.siteWind, + }, + spacing: { + run: (state, value) => state.setSpacing(spacingOf(value)), + current: (state) => String(state.spacing), + }, + nacelle: { + run: (state, value) => + state.setView({ cutaway: parseOption(value, NACELLE_CHOICE_IDS) === OPEN }), + current: (state) => (state.view.cutaway ? OPEN : CLOSED), + }, + windAt: { + run: (state, value) => state.setWindOverride(overrideOf(parseOption(value, WIND_AT_IDS))), + current: (state) => windAtOf(state.windOverride), + }, +}; diff --git a/explainers/wind-farm/src/ui/dock.ts b/explainers/wind-farm/src/ui/dock.ts new file mode 100644 index 00000000..9f690b6b --- /dev/null +++ b/explainers/wind-farm/src/ui/dock.ts @@ -0,0 +1,45 @@ +import type { Choice, ViewToggle } from '@core/explainer'; +import { parseOption } from '@core/ui/parse'; +import { SITE_WIND_IDS } from '../ids'; +import type { WindFarmStoreState } from '../state'; + +const STREAMLINES_ICON = + ''; +const WAKES_ICON = + ''; +const CABLES_ICON = + ''; +const LABELS_ICON = + ''; + +export const WIND_FARM_CHOICES: readonly Choice[] = [ + { + id: 'siteWind', + labelKey: 'controls.siteWind', + shortcut: 'W', + options: SITE_WIND_IDS.map((site) => ({ + value: site, + labelKey: `controls.siteWindOptions.${site}`, + })), + select: (state) => state.siteWind, + apply: (state, value) => state.setSiteWind(parseOption(value, SITE_WIND_IDS)), + }, +]; + +function viewToggle(view: string, shortcut: string, icon: string): ViewToggle { + return { + view, + shortcut, + nameKey: `controls.views.${view}.name`, + shortKey: `controls.views.${view}.short`, + hintKey: `controls.views.${view}.hint`, + icon, + }; +} + +export const VIEW_TOGGLES: readonly ViewToggle[] = [ + viewToggle('streamlines', 'F', STREAMLINES_ICON), + viewToggle('wakes', 'K', WAKES_ICON), + viewToggle('cables', 'C', CABLES_ICON), + viewToggle('labels', 'L', LABELS_ICON), +]; From b534881c5bbcff9e44bb7ed2cf1ebae8325b87c8 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:17:32 +0200 Subject: [PATCH 013/132] feat(wind-farm): add the power curve canvas --- .../src/ui/powerCurveView.dom.test.ts | 104 +++++++++ explainers/wind-farm/src/ui/powerCurveView.ts | 214 ++++++++++++++++++ explainers/wind-farm/src/ui/testing.ts | 120 ++++++++++ 3 files changed, 438 insertions(+) create mode 100644 explainers/wind-farm/src/ui/powerCurveView.dom.test.ts create mode 100644 explainers/wind-farm/src/ui/powerCurveView.ts create mode 100644 explainers/wind-farm/src/ui/testing.ts diff --git a/explainers/wind-farm/src/ui/powerCurveView.dom.test.ts b/explainers/wind-farm/src/ui/powerCurveView.dom.test.ts new file mode 100644 index 00000000..35bec4c8 --- /dev/null +++ b/explainers/wind-farm/src/ui/powerCurveView.dom.test.ts @@ -0,0 +1,104 @@ +import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; +import { CanvasSurface } from '@core/ui/canvasSurface'; +import type { CanvasFrame } from '@core/ui/canvasSurface'; +import { RATED_KW, RATED_WIND_MS } from '../model'; +import { createWindFarmStore } from '../state'; +import { + AXIS_POWER_KW, + AXIS_WIND_MS, + PowerCurveView, + mountPowerCurve, + paintPowerCurve, + plotOf, + xOfWind, + yOfPower, +} from './powerCurveView'; +import { TEST_LOCALE, initTestLocale } from './testing'; + +const FRAME: CanvasFrame = { width: 600, height: 300, ratio: 2, fontFamily: 'sans-serif' }; +const CHAR_WIDTH = 6; +const { curve } = TEST_LOCALE.chapters; + +interface Arc { + x: number; + y: number; +} + +function recordingContext() { + const texts: string[] = []; + const arcs: Arc[] = []; + const noop = () => {}; + const context = { + font: '', + fillStyle: '', + strokeStyle: '', + lineWidth: 1, + textAlign: 'left', + textBaseline: 'alphabetic', + measureText: (text: string) => ({ width: text.length * CHAR_WIDTH }), + fillText: (text: string) => texts.push(text), + arc: (x: number, y: number) => arcs.push({ x, y }), + save: noop, + restore: noop, + rect: noop, + clip: noop, + beginPath: noop, + moveTo: noop, + lineTo: noop, + stroke: noop, + fill: noop, + }; + return { context: context as unknown as CanvasRenderingContext2D, texts, arcs }; +} + +describe('power curve', () => { + beforeAll(() => initTestLocale()); + + afterEach(() => vi.restoreAllMocks()); + + it('maps 0 to 30 m/s across and 0 to 10 MW up, clipping above the top', () => { + const plot = plotOf(FRAME); + expect(xOfWind(plot, 0)).toBe(plot.left); + expect(xOfWind(plot, AXIS_WIND_MS)).toBe(plot.right); + expect(yOfPower(plot, 0)).toBe(plot.bottom); + expect(yOfPower(plot, AXIS_POWER_KW)).toBe(plot.top); + expect(yOfPower(plot, AXIS_POWER_KW * 2)).toBe(plot.top); + }); + + it('labels both axes and all three lines', () => { + const { context, texts } = recordingContext(); + paintPowerCurve(context, FRAME, RATED_WIND_MS); + [curve.axisWind, curve.axisPower, curve.lineWind, curve.lineBetz, curve.lineTurbine].forEach( + (label) => expect(texts).toContain(label), + ); + }); + + it('puts the dot on the turbine curve at the live wind', () => { + const { context, arcs } = recordingContext(); + paintPowerCurve(context, FRAME, RATED_WIND_MS); + const plot = plotOf(FRAME); + expect(arcs).toEqual([{ x: xOfWind(plot, RATED_WIND_MS), y: yOfPower(plot, RATED_KW) }]); + }); + + it('repaints only when the live wind changes', () => { + const paint = vi.spyOn(CanvasSurface.prototype, 'paint'); + document.body.innerHTML = ''; + const store = createWindFarmStore({ playing: false, windOverride: RATED_WIND_MS }); + const dispose = mountPowerCurve(document, store); + expect(paint).toHaveBeenCalledTimes(1); + store.getState().setSpacing(5); + expect(paint).toHaveBeenCalledTimes(1); + store.getState().setWindOverride(RATED_WIND_MS + 1); + expect(paint).toHaveBeenCalledTimes(2); + dispose(); + }); + + it('skips a repaint for the same wind and language', () => { + const paint = vi.spyOn(CanvasSurface.prototype, 'paint'); + const view = new PowerCurveView(document.createElement('canvas')); + view.draw(RATED_WIND_MS); + view.draw(RATED_WIND_MS); + expect(paint).toHaveBeenCalledTimes(1); + view.dispose(); + }); +}); diff --git a/explainers/wind-farm/src/ui/powerCurveView.ts b/explainers/wind-farm/src/ui/powerCurveView.ts new file mode 100644 index 00000000..35586675 --- /dev/null +++ b/explainers/wind-farm/src/ui/powerCurveView.ts @@ -0,0 +1,214 @@ +import { withAlpha } from '@core/color'; +import { currentLanguage, t } from '@core/i18n'; +import { FULL_TURN } from '@core/math'; +import { CanvasSurface, canvasFont } from '@core/ui/canvasSurface'; +import type { CanvasFrame } from '@core/ui/canvasSurface'; +import type { Disposer } from '@core/ui/disposers'; +import { requireElement } from '@core/ui/dom'; +import { watchLocalized } from '@core/ui/subscribe'; +import { BETZ_LIMIT, turbinePowerKw, windPowerKw } from '../model'; +import { WIND_OVERRIDE_RANGE, liveWind } from '../state'; +import type { WindFarmStore } from '../state'; +import { THEME } from '../theme'; + +export const AXIS_WIND_MS = WIND_OVERRIDE_RANGE.max; +export const AXIS_POWER_KW = 10_000; +const WIND_TICK_MS = 5; +const POWER_TICK_KW = 2_000; +const KW_PER_MW = 1000; +const SAMPLE_STEP_MS = 0.1; +const TICK_FONT_PX = 11; +const LABEL_FONT_PX = 12; +const PAD_LEFT = 30; +const PAD_RIGHT = 10; +const PAD_TOP = 22; +const PAD_BOTTOM = 34; +const TICK_GAP = 6; +const LEGEND_LINE = 16; +const LEGEND_GAP = 6; +const LEGEND_ROW = 16; +const LEGEND_INSET = 8; +const CURVE_WIDTH = 2; +const DOT_RADIUS = 4.5; +const DOT_RING = 1.5; +const GRID_ALPHA = 0.14; +const POWER_CURVE_CANVAS = '[data-canvas="power-curve"]'; + +export const CURVE_COLORS = { + grid: withAlpha(THEME.muted, GRID_ALPHA), + tick: THEME.muted, + label: THEME.text, + wind: THEME.wind, + betz: THEME.evening, + turbine: THEME.afternoon, + dotRing: THEME.text, +} as const; + +interface Line { + key: string; + color: string; + powerKw: (wind: number) => number; +} + +const LINES: readonly Line[] = [ + { key: 'chapters.curve.lineWind', color: CURVE_COLORS.wind, powerKw: windPowerKw }, + { + key: 'chapters.curve.lineBetz', + color: CURVE_COLORS.betz, + powerKw: (wind) => BETZ_LIMIT * windPowerKw(wind), + }, + { key: 'chapters.curve.lineTurbine', color: CURVE_COLORS.turbine, powerKw: turbinePowerKw }, +]; + +export interface Plot { + left: number; + right: number; + top: number; + bottom: number; +} + +export function plotOf(frame: CanvasFrame): Plot { + return { + left: PAD_LEFT, + right: frame.width - PAD_RIGHT, + top: PAD_TOP, + bottom: frame.height - PAD_BOTTOM, + }; +} + +export function xOfWind(plot: Plot, wind: number): number { + return plot.left + (wind / AXIS_WIND_MS) * (plot.right - plot.left); +} + +export function yOfPower(plot: Plot, kw: number): number { + const share = Math.min(kw, AXIS_POWER_KW) / AXIS_POWER_KW; + return plot.bottom - share * (plot.bottom - plot.top); +} + +function ticks(limit: number, step: number): number[] { + return Array.from({ length: Math.floor(limit / step) + 1 }, (_, index) => index * step); +} + +function paintGrid(context: CanvasRenderingContext2D, frame: CanvasFrame, plot: Plot): void { + context.strokeStyle = CURVE_COLORS.grid; + context.lineWidth = 1; + context.font = canvasFont(frame, TICK_FONT_PX); + context.fillStyle = CURVE_COLORS.tick; + context.textBaseline = 'middle'; + context.textAlign = 'right'; + ticks(AXIS_POWER_KW, POWER_TICK_KW).forEach((kw) => { + const y = yOfPower(plot, kw); + context.beginPath(); + context.moveTo(plot.left, y); + context.lineTo(plot.right, y); + context.stroke(); + context.fillText(String(kw / KW_PER_MW), plot.left - TICK_GAP, y); + }); + context.textBaseline = 'top'; + context.textAlign = 'center'; + ticks(AXIS_WIND_MS, WIND_TICK_MS).forEach((wind) => { + context.fillText(String(wind), xOfWind(plot, wind), plot.bottom + TICK_GAP); + }); +} + +function paintAxisLabels(context: CanvasRenderingContext2D, frame: CanvasFrame, plot: Plot): void { + context.font = canvasFont(frame, LABEL_FONT_PX); + context.fillStyle = CURVE_COLORS.label; + context.textBaseline = 'bottom'; + context.textAlign = 'center'; + context.fillText(t('chapters.curve.axisWind'), (plot.left + plot.right) / 2, frame.height); + context.textBaseline = 'top'; + context.textAlign = 'left'; + context.fillText(t('chapters.curve.axisPower'), 0, 0); +} + +function paintLine(context: CanvasRenderingContext2D, plot: Plot, line: Line): void { + context.save(); + context.beginPath(); + context.rect(plot.left, plot.top, plot.right - plot.left, plot.bottom - plot.top); + context.clip(); + context.strokeStyle = line.color; + context.lineWidth = CURVE_WIDTH; + context.beginPath(); + ticks(AXIS_WIND_MS, SAMPLE_STEP_MS).forEach((wind, index) => { + const x = xOfWind(plot, wind); + const y = yOfPower(plot, line.powerKw(wind)); + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.stroke(); + context.restore(); +} + +function paintLegend(context: CanvasRenderingContext2D, frame: CanvasFrame, plot: Plot): void { + context.font = canvasFont(frame, LABEL_FONT_PX); + context.textBaseline = 'middle'; + context.textAlign = 'right'; + context.lineWidth = CURVE_WIDTH; + LINES.forEach((line, index) => { + const y = plot.top + LEGEND_INSET + index * LEGEND_ROW; + const textRight = plot.right - LEGEND_INSET; + const lineRight = textRight - context.measureText(t(line.key)).width - LEGEND_GAP; + context.fillStyle = CURVE_COLORS.label; + context.fillText(t(line.key), textRight, y); + context.strokeStyle = line.color; + context.beginPath(); + context.moveTo(lineRight - LEGEND_LINE, y); + context.lineTo(lineRight, y); + context.stroke(); + }); +} + +function paintDot(context: CanvasRenderingContext2D, plot: Plot, wind: number): void { + context.beginPath(); + context.arc(xOfWind(plot, wind), yOfPower(plot, turbinePowerKw(wind)), DOT_RADIUS, 0, FULL_TURN); + context.fillStyle = CURVE_COLORS.turbine; + context.fill(); + context.strokeStyle = CURVE_COLORS.dotRing; + context.lineWidth = DOT_RING; + context.stroke(); +} + +export function paintPowerCurve( + context: CanvasRenderingContext2D, + frame: CanvasFrame, + wind: number, +): void { + const plot = plotOf(frame); + paintGrid(context, frame, plot); + paintAxisLabels(context, frame, plot); + LINES.forEach((line) => paintLine(context, plot, line)); + paintLegend(context, frame, plot); + paintDot(context, plot, wind); +} + +export class PowerCurveView { + private readonly surface: CanvasSurface; + private paintedWind: number | null = null; + private language = ''; + + constructor(canvas: HTMLCanvasElement) { + this.surface = new CanvasSurface(canvas); + } + + draw(wind: number): void { + const language = currentLanguage(); + if (wind === this.paintedWind && language === this.language) return; + this.paintedWind = wind; + this.language = language; + this.surface.paint((context, frame) => paintPowerCurve(context, frame, wind)); + } + + dispose(): void { + this.surface.dispose(); + } +} + +export function mountPowerCurve(root: Document, store: WindFarmStore): Disposer { + const view = new PowerCurveView(requireElement(root, POWER_CURVE_CANVAS)); + const stopWatching = watchLocalized(store, liveWind, (wind) => view.draw(wind)); + return () => { + stopWatching(); + view.dispose(); + }; +} diff --git a/explainers/wind-farm/src/ui/testing.ts b/explainers/wind-farm/src/ui/testing.ts new file mode 100644 index 00000000..24bbc840 --- /dev/null +++ b/explainers/wind-farm/src/ui/testing.ts @@ -0,0 +1,120 @@ +import { initI18n } from '@core/i18n'; + +export const TEST_LOCALE = { + readouts: { + wind: 'Wind', + power: 'Power', + farm: 'Farm', + state: { + idle: 'Waiting', + partial: 'Catching wind', + full: 'Full power', + rampDown: 'Easing off', + stopping: 'Stopping', + parked: 'Parked', + starting: 'Starting', + }, + }, + units: { + metresPerSecond: '{{value}} m/s', + speedPair: '{{ms}} m/s ({{kmh}} km/h)', + rpm: '{{value}} rpm', + degrees: '{{value}}°', + mw: '{{value}} MW', + mwh: '{{value}} MWh', + percent: '{{value}} %', + metres: '{{value}} m', + seconds: '{{value}} s', + count: '{{value}}', + hectares: '{{value}} ha', + squareKm: '{{value}} km²', + volts: '{{value}} V', + gramsPerKwh: '{{value}} g/kWh', + months: '{{value}} months', + }, + chapters: { + farm: { spacingValue: '{{metres}} m ({{diameters}} D)' }, + tower: { stopped: 'Not turning', sweptValue: '{{area}} m² ({{pitches}} pitches)' }, + nacelle: { + gearValue: 'about 1 to {{ratio}}', + brakeValue: { released: 'Released', holding: 'Holding' }, + }, + curve: { + axisWind: 'Wind speed (m/s)', + axisPower: 'Power (MW)', + lineWind: 'Power in the wind', + lineBetz: 'Betz limit', + lineTurbine: 'This turbine', + }, + grid: { voltagesValue: '{{generator}} V, {{collector}} kV, {{grid}} kV' }, + }, +}; + +export function initTestLocale(): Promise { + return initI18n({ en: () => Promise.resolve(TEST_LOCALE) }); +} + +export function fill(template: string, values: Readonly>): string { + return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => values[name] ?? ''); +} + +const PLACEHOLDER = /\{\{|\}\}/; + +export function isFilled(text: string | null | undefined): boolean { + return Boolean(text) && !PLACEHOLDER.test(text ?? ''); +} + +function readouts(ids: readonly string[]): string { + const rows = ids.map( + (id) => `
${id}
`, + ); + return `
${rows.join('')}
`; +} + +function chips(action: string, values: readonly string[]): string { + const buttons = values.map( + (value) => + ``, + ); + return `
${buttons.join('')}
`; +} + +function windRange(id: string): string { + return `
`; +} + +export const CHAPTER_FIXTURE = [ + readouts([ + 'turbineCount', + 'farmRated', + 'rowSpacing', + 'acrossSpacing', + 'builtLand', + 'projectArea', + ]), + windRange('tower-wind'), + readouts(['rotorRpm', 'turnTime']), + chips('windAt', ['day', 'rated']), + readouts(['tipSpeed', 'tipHeight', 'bladeLength', 'sweptArea']), + chips('nacelle', ['closed', 'open']), + readouts(['shaftRpm', 'generatorRpm', 'gearRatio', 'pitchAngle', 'brake', 'generatorVolts']), + windRange('curve-wind'), + readouts(['operatingState', 'curvePower']), + chips('windAt', ['day', 'cutIn', 'peak', 'rated', 'rampDown', 'cutOut']), + '
', + readouts(['windPower', 'powerCoefficient', 'betzShare', 'curvePitch']), + chips('spacing', ['5', '7', '9']), + readouts(['wakeSpacing', 'wakeDeficit', 'wakeLoss', 'windTop', 'windBottom']), + chips('siteWind', ['calm', 'typical', 'windy']), + readouts([ + 'farmOutput', + 'energyToday', + 'homes', + 'capacityFactor', + 'voltages', + 'annualEnergy', + 'annualHomes', + 'carbon', + 'payback', + ]), +].join(''); From 736588f340b9775258fe047e7b3e3914fd1023e4 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:17:41 +0200 Subject: [PATCH 014/132] feat(wind-farm): add the farm energy and homes model --- explainers/wind-farm/src/model/energy.test.ts | 103 ++++++++++++++++++ explainers/wind-farm/src/model/energy.ts | 79 ++++++++++++++ explainers/wind-farm/src/model/index.ts | 1 + 3 files changed, 183 insertions(+) create mode 100644 explainers/wind-farm/src/model/energy.test.ts create mode 100644 explainers/wind-farm/src/model/energy.ts diff --git a/explainers/wind-farm/src/model/energy.test.ts b/explainers/wind-farm/src/model/energy.test.ts new file mode 100644 index 00000000..d63cf67d --- /dev/null +++ b/explainers/wind-farm/src/model/energy.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vitest'; +import { FARM_RATED_KW } from './constants'; +import { operatingAt, parkedWindow } from './control'; +import { CYCLE_MINUTES, dayWind, windFromDeg } from './day'; +import { + annualHomesPerTurbine, + dayCapacityFactor, + dayEnergyMwh, + energyTable, + energyTodayMwh, + homesNow, +} from './energy'; +import { farmPowerKw } from './wakes'; + +const SAMPLE_COUNT = 289; +const NOON = 720; + +describe('energyTable', () => { + it('samples the farm every 5 minutes through the whole day', () => { + const table = energyTable('typical', 7); + expect(table.stepMinutes).toBe(5); + expect(table.farmKw).toHaveLength(SAMPLE_COUNT); + expect(table.cumulativeMwh).toHaveLength(SAMPLE_COUNT); + expect(table.cumulativeMwh[0]).toBe(0); + }); + + it('reuses the table for the same site and spacing', () => { + expect(energyTable('typical', 7)).toBe(energyTable('typical', 7)); + expect(energyTable('typical', 7)).not.toBe(energyTable('typical', 9)); + }); + + it('follows the waked farm while producing and drops to zero while parked', () => { + const { farmKw } = energyTable('typical', 7); + const noonSample = NOON / 5; + expect(farmKw[noonSample]).toBeCloseTo( + farmPowerKw(dayWind(NOON, 'typical'), windFromDeg(NOON), 7), + 9, + ); + const { stop, restart } = parkedWindow('typical') ?? { stop: 0, restart: 0 }; + const parkedSample = Math.ceil((stop + restart) / 2 / 5) * 5; + expect(operatingAt(parkedSample, 'typical').producing).toBe(false); + expect(farmKw[parkedSample / 5]).toBe(0); + expect(farmKw[0]).toBe(0); + }); + + it('never runs above the rated farm', () => { + expect(Math.max(...energyTable('windy', 9).farmKw)).toBeLessThanOrEqual(FARM_RATED_KW); + }); +}); + +describe('energy today', () => { + it('climbs through the day and ends on the day total', () => { + const samples = [0, 300, 600, 900, 1200, 1435].map((minute) => + energyTodayMwh(minute, 'typical', 7), + ); + samples + .slice(1) + .forEach((total, index) => expect(total).toBeGreaterThanOrEqual(samples[index])); + expect(energyTodayMwh(0, 'typical', 7)).toBe(0); + expect(dayEnergyMwh('typical', 7)).toBeCloseTo(1182.8, 1); + }); + + it('reads linearly between the samples', () => { + const before = energyTodayMwh(NOON, 'typical', 7); + const after = energyTodayMwh(NOON + 5, 'typical', 7); + expect(energyTodayMwh(NOON + 2, 'typical', 7)).toBeCloseTo( + before + ((after - before) * 2) / 5, + 9, + ); + }); + + it('starts over at midnight', () => { + expect(energyTodayMwh(CYCLE_MINUTES, 'typical', 7)).toBe(0); + }); +}); + +describe('day capacity factor', () => { + it('lands near 0.37, 0.43 and 0.46 for the calm, typical and windy sites', () => { + expect(dayCapacityFactor('calm', 7)).toBeCloseTo(0.37, 2); + expect(dayCapacityFactor('typical', 7)).toBeCloseTo(0.43, 2); + expect(dayCapacityFactor('windy', 7)).toBeCloseTo(0.46, 2); + }); + + it('rises from the calm site to the windy one', () => { + const calm = dayCapacityFactor('calm', 7); + const typical = dayCapacityFactor('typical', 7); + expect(typical).toBeGreaterThan(calm); + expect(dayCapacityFactor('windy', 7)).toBeGreaterThan(typical); + }); +}); + +describe('homes', () => { + it('counts one home for each 1.23 kW of output', () => { + expect(homesNow(1.2318)).toBeCloseTo(1, 3); + expect(homesNow(4200)).toBeCloseTo(3409.5, 1); + expect(homesNow(0)).toBe(0); + }); + + it('supplies 1,361 full homes a year from one turbine', () => { + expect(annualHomesPerTurbine()).toBeCloseTo(1361.5, 1); + expect(Math.floor(annualHomesPerTurbine())).toBe(1361); + }); +}); diff --git a/explainers/wind-farm/src/model/energy.ts b/explainers/wind-farm/src/model/energy.ts new file mode 100644 index 00000000..6270a7da --- /dev/null +++ b/explainers/wind-farm/src/model/energy.ts @@ -0,0 +1,79 @@ +import { lerp } from '@core/math'; +import type { SiteWindId, SpacingD } from '../ids'; +import { + ANNUAL_MWH_PER_TURBINE, + FARM_RATED_KW, + HOME_KWH_PER_YEAR, + HOURS_PER_YEAR, +} from './constants'; +import { operatingAt } from './control'; +import { CYCLE_MINUTES, MINUTES_PER_HOUR, dayWind, windFromDeg, wrapMinute } from './day'; +import { farmPowerKw } from './wakes'; + +export interface EnergyTable { + stepMinutes: number; + farmKw: readonly number[]; + cumulativeMwh: readonly number[]; +} + +const SAMPLE_STEP_MINUTES = 5; +const SAMPLE_COUNT = CYCLE_MINUTES / SAMPLE_STEP_MINUTES + 1; +const KW_PER_MW = 1000; +const HOURS_PER_DAY = CYCLE_MINUTES / MINUTES_PER_HOUR; +const HOME_AVERAGE_KW = HOME_KWH_PER_YEAR / HOURS_PER_YEAR; + +function farmKwAt(minute: number, site: SiteWindId, spacing: SpacingD): number { + if (!operatingAt(minute, site).producing) return 0; + return farmPowerKw(dayWind(minute, site), windFromDeg(minute), spacing); +} + +function cumulativeMwh(farmKw: readonly number[]): number[] { + const stepHours = SAMPLE_STEP_MINUTES / MINUTES_PER_HOUR; + let total = 0; + return farmKw.map((kw, index) => { + if (index > 0) total += (((farmKw[index - 1] + kw) / 2) * stepHours) / KW_PER_MW; + return total; + }); +} + +function buildEnergyTable(site: SiteWindId, spacing: SpacingD): EnergyTable { + const farmKw = Array.from({ length: SAMPLE_COUNT }, (_, index) => + farmKwAt(index * SAMPLE_STEP_MINUTES, site, spacing), + ); + return { stepMinutes: SAMPLE_STEP_MINUTES, farmKw, cumulativeMwh: cumulativeMwh(farmKw) }; +} + +const energyTables = new Map(); + +export function energyTable(site: SiteWindId, spacing: SpacingD): EnergyTable { + const key = `${site}:${spacing}`; + const cached = energyTables.get(key); + if (cached) return cached; + const table = buildEnergyTable(site, spacing); + energyTables.set(key, table); + return table; +} + +export function energyTodayMwh(minute: number, site: SiteWindId, spacing: SpacingD): number { + const { cumulativeMwh: totals } = energyTable(site, spacing); + const position = wrapMinute(minute) / SAMPLE_STEP_MINUTES; + const index = Math.floor(position); + return lerp(totals[index], totals[index + 1], position - index); +} + +export function dayEnergyMwh(site: SiteWindId, spacing: SpacingD): number { + const { cumulativeMwh: totals } = energyTable(site, spacing); + return totals[totals.length - 1]; +} + +export function dayCapacityFactor(site: SiteWindId, spacing: SpacingD): number { + return dayEnergyMwh(site, spacing) / ((FARM_RATED_KW * HOURS_PER_DAY) / KW_PER_MW); +} + +export function homesNow(farmKw: number): number { + return farmKw / HOME_AVERAGE_KW; +} + +export function annualHomesPerTurbine(): number { + return (ANNUAL_MWH_PER_TURBINE * KW_PER_MW) / HOME_KWH_PER_YEAR; +} diff --git a/explainers/wind-farm/src/model/index.ts b/explainers/wind-farm/src/model/index.ts index 280b7978..745b20bf 100644 --- a/explainers/wind-farm/src/model/index.ts +++ b/explainers/wind-farm/src/model/index.ts @@ -5,3 +5,4 @@ export * from './layout'; export * from './control'; export * from './power'; export * from './wakes'; +export * from './energy'; From 8b463949a319748f025583f4e4266d742f43b883 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:18:04 +0200 Subject: [PATCH 015/132] feat(wind-farm): bind the scene to the store and mount it --- explainers/wind-farm/src/scene/bindings.ts | 46 ++++++++++++++++++ explainers/wind-farm/src/scene/index.ts | 54 ++++++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 explainers/wind-farm/src/scene/bindings.ts create mode 100644 explainers/wind-farm/src/scene/index.ts diff --git a/explainers/wind-farm/src/scene/bindings.ts b/explainers/wind-farm/src/scene/bindings.ts new file mode 100644 index 00000000..ca73c68e --- /dev/null +++ b/explainers/wind-farm/src/scene/bindings.ts @@ -0,0 +1,46 @@ +import { bindPresets } from '@core/scene/presetBinder'; +import type { LabelPolicy, PresetTargets } from '@core/scene/presetBinder'; +import type { AssemblyState } from '../ids'; +import { PRESETS, createAssemblyState, writeAssemblyState } from '../state'; +import type { AssemblySource, Preset, WindFarmStore, WindFarmStoreState } from '../state'; +import type { WindFarmController } from './controller'; + +export interface SceneTargets extends PresetTargets { + windFarm: Pick; + labelVisibility: LabelPolicy; +} + +class DoubleBufferedAssemblyState { + private heldByAssembly: AssemblyState; + private free: AssemblyState; + + constructor(source: AssemblySource) { + this.heldByAssembly = createAssemblyState(source); + this.free = createAssemblyState(source); + } + + handOver(source: AssemblySource): AssemblyState { + const next = writeAssemblyState(this.free, source); + this.free = this.heldByAssembly; + this.heldByAssembly = next; + return next; + } +} + +export function bindStore(store: WindFarmStore, targets: SceneTargets): () => void { + const { windFarm, labelVisibility } = targets; + const assemblyState = new DoubleBufferedAssemblyState(store.getState()); + const push = (state: AssemblySource) => windFarm.setState(assemblyState.handOver(state)); + windFarm.build(assemblyState.handOver(store.getState())); + const unsubscribers = [ + store.subscribe(push), + bindPresets(targets, store, { + presets: PRESETS, + views: windFarm.views, + labels: labelVisibility, + prepare: push, + onView: (_view, state) => push(state), + }), + ]; + return () => unsubscribers.forEach((unsubscribe) => unsubscribe()); +} diff --git a/explainers/wind-farm/src/scene/index.ts b/explainers/wind-farm/src/scene/index.ts new file mode 100644 index 00000000..22605e78 --- /dev/null +++ b/explainers/wind-farm/src/scene/index.ts @@ -0,0 +1,54 @@ +import { createLabelVisibility } from '@core/scene/presetBinder'; +import type { SceneOptions, SceneShell } from '@core/scene/shell'; +import type { PartId } from '../ids'; +import type { WindFarmStore } from '../state'; +import { THEME } from '../theme'; +import { bindStore } from './bindings'; +import { WindFarmController } from './controller'; +import { LABEL_PRIORITY } from './partInfo'; + +export { bindStore } from './bindings'; +export type { SceneTargets } from './bindings'; +export { WindFarmController } from './controller'; +export type { ViewFramer, WindFarmControllerDependencies } from './controller'; + +const HAZE = { colour: THEME.haze, near: 9000, far: 28000 } as const; + +const SCENE_LIMITS = { + cameraNear: 2, + cameraFar: 30000, + maxPolarAngle: Math.PI * 0.47, + cameraMinDistance: 6, + cameraMaxDistance: 25000, +} as const; + +const HIGHLIGHT_DIM = { saturation: 0.55, brightness: 0.62, emissive: 0.4 } as const; + +const UNDIMMED_PARTS: readonly PartId[] = ['land', 'farmLand', 'streamlinesGroup', 'windArrows']; + +export const SCENE_OPTIONS: SceneOptions = { + background: HAZE.colour, + fog: { color: HAZE.colour, near: HAZE.near, far: HAZE.far }, + stage: false, + camera: { + near: SCENE_LIMITS.cameraNear, + far: SCENE_LIMITS.cameraFar, + maxPolarAngle: SCENE_LIMITS.maxPolarAngle, + distance: { min: SCENE_LIMITS.cameraMinDistance, max: SCENE_LIMITS.cameraMaxDistance }, + }, + gaugeSide: 'top', + highlight: { dim: HIGHLIGHT_DIM, undimmed: UNDIMMED_PARTS }, +}; + +export function mountWindFarmScene(shell: SceneShell, store: WindFarmStore): () => void { + const windFarm = new WindFarmController(shell); + const removeFrame = shell.onFrame((deltaSeconds) => windFarm.update(deltaSeconds)); + const labelVisibility = createLabelVisibility(shell, LABEL_PRIORITY); + const unbind = bindStore(store, { windFarm, labelVisibility, ...shell }); + return () => { + removeFrame(); + labelVisibility.dispose(); + unbind(); + windFarm.dispose(); + }; +} From 3fade338c38808cb855157c9edc9610330279894 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:18:04 +0200 Subject: [PATCH 016/132] test(wind-farm): cover the scene bindings, camera views and controller --- .../wind-farm/src/scene/bindings.test.ts | 83 ++++++++ .../wind-farm/src/scene/cameraViews.test.ts | 116 ++++++++++++ .../src/scene/controller.dom.test.ts | 177 ++++++++++++++++++ 3 files changed, 376 insertions(+) create mode 100644 explainers/wind-farm/src/scene/bindings.test.ts create mode 100644 explainers/wind-farm/src/scene/cameraViews.test.ts create mode 100644 explainers/wind-farm/src/scene/controller.dom.test.ts diff --git a/explainers/wind-farm/src/scene/bindings.test.ts b/explainers/wind-farm/src/scene/bindings.test.ts new file mode 100644 index 00000000..9cbde8c8 --- /dev/null +++ b/explainers/wind-farm/src/scene/bindings.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { AssemblyState } from '../ids'; +import { createWindFarmStore } from '../state'; +import { bindStore } from './bindings'; +import type { SceneTargets } from './bindings'; + +const LATER_PHASE = 0.4; +const STRONG_WIND_MS = 15; + +function fakeTargets() { + const received: AssemblyState[] = []; + const snapshots: AssemblyState[] = []; + const record = (state: AssemblyState) => { + received.push(state); + snapshots.push(structuredClone(state)); + }; + const frame = vi.fn(); + const setWanted = vi.fn(); + const targets: SceneTargets = { + windFarm: { build: record, setState: record, views: { frame } }, + labelVisibility: { setWanted }, + highlighter: { setHighlight: vi.fn() }, + labels: { show: vi.fn() }, + }; + return { received, snapshots, frame, setWanted, targets }; +} + +describe('wind farm scene bindings', () => { + it('builds the scene from the store and frames the first chapter', () => { + const { received, frame, targets } = fakeTargets(); + const store = createWindFarmStore(); + bindStore(store, targets); + const { phase, spacing, view } = store.getState(); + expect(received[0]).toMatchObject({ scene: 'farm', phase, view, farm: { spacing } }); + expect(frame).toHaveBeenCalledWith('farmAerial', false, undefined); + }); + + it('swaps the scene and hands over the view flags before framing the next camera', () => { + const { received, frame, targets } = fakeTargets(); + const store = createWindFarmStore(); + bindStore(store, targets); + let handedBeforeFrame: AssemblyState | undefined; + frame.mockImplementationOnce(() => (handedBeforeFrame = received.at(-1))); + store.getState().applyPreset('nacelle'); + expect(frame).toHaveBeenLastCalledWith('nacelleCutaway', true, undefined); + expect(handedBeforeFrame).toMatchObject({ scene: 'turbine', view: { cutaway: true } }); + }); + + it('applies a view toggle at once and refreshes the labels', () => { + const { received, setWanted, targets } = fakeTargets(); + const store = createWindFarmStore(); + bindStore(store, targets); + const before = setWanted.mock.calls.length; + const shown = store.getState().view.cables; + store.getState().toggleView('cables'); + expect(received.at(-1)?.view.cables).toBe(!shown); + expect(setWanted.mock.calls.length).toBeGreaterThan(before); + }); + + it('alternates two buffers so the held state is never rewritten', () => { + const { received, snapshots, targets } = fakeTargets(); + const store = createWindFarmStore(); + bindStore(store, targets); + store.getState().pause(); + store.getState().setPhase(LATER_PHASE); + store.getState().setWindOverride(STRONG_WIND_MS); + const [first, second, third] = received.slice(-3); + expect(first).not.toBe(second); + expect(third).toBe(first); + expect(received.at(-2)).toEqual(snapshots.at(-2)); + expect(third?.wind.speed).toBe(STRONG_WIND_MS); + }); + + it('stops handing over states once unbound', () => { + const { received, targets } = fakeTargets(); + const store = createWindFarmStore(); + const unbind = bindStore(store, targets); + unbind(); + const count = received.length; + store.getState().setPhase(LATER_PHASE); + expect(received).toHaveLength(count); + }); +}); diff --git a/explainers/wind-farm/src/scene/cameraViews.test.ts b/explainers/wind-farm/src/scene/cameraViews.test.ts new file mode 100644 index 00000000..ea929a33 --- /dev/null +++ b/explainers/wind-farm/src/scene/cameraViews.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import type { CameraView } from '../ids'; +import { CAMERA_VIEWS, FOLLOWED_ANCHOR, STAGE_VARIANTS, stageVariant } from './cameraViews'; +import type { StageVariant } from './cameraViews'; +import { SCENE_OPTIONS } from '.'; + +const VIEWS = Object.keys(CAMERA_VIEWS) as CameraView[]; +const FOLLOWING_VIEWS: readonly CameraView[] = ['nacelleCutaway', 'rotorQuarter']; +const DEGREES_PER_RADIAN = 180 / Math.PI; +const RIGHT_ANGLE_DEG = 90; +const WITHIN_FIVE_DEGREES = -1; +const AERIAL_DEG = 45; +const WAKE_DEG = 25; +const GRID_DEG = 55; +const LOW_VIEW_MAX_DEG = 10; +const OPENING_MIN_DEG = 30; +const DISTANCES: Readonly> = { + farmAerial: { min: 2000, max: 20000 }, + turbineTall: { min: 150, max: 2500 }, + nacelleCutaway: { min: 8, max: 150 }, + rotorQuarter: { min: 80, max: 1200 }, + wakeStreaks: { min: 1500, max: 18000 }, + gridSubstation: { min: 600, max: 15000 }, +}; + +function elevation(view: CameraView, variant: StageVariant): number { + const [x, y, z] = CAMERA_VIEWS[view].direction[variant]; + return Math.atan2(y, Math.hypot(x, z)) * DEGREES_PER_RADIAN; +} + +function eachVariant(check: (variant: StageVariant) => void): void { + STAGE_VARIANTS.forEach(check); +} + +describe('camera views', () => { + it('offers the six views, the nacelle and rotor ones turning with the yaw', () => { + expect(VIEWS).toEqual([ + 'farmAerial', + 'turbineTall', + 'nacelleCutaway', + 'rotorQuarter', + 'wakeStreaks', + 'gridSubstation', + ]); + expect(FOLLOWED_ANCHOR).toBe('yawPivot'); + VIEWS.forEach((view) => + expect(CAMERA_VIEWS[view].follow, view).toBe( + FOLLOWING_VIEWS.includes(view) ? 'heading' : undefined, + ), + ); + expect(CAMERA_VIEWS.nacelleCutaway.region).toBe('nacelle'); + expect(CAMERA_VIEWS.rotorQuarter.region).toBe('rotor'); + }); + + it('limits the zoom of each view', () => { + VIEWS.forEach((view) => expect(CAMERA_VIEWS[view].distance, view).toEqual(DISTANCES[view])); + }); + + it('picks the phone framing below 600 pixels of stage width', () => { + expect(stageVariant(599)).toBe('phone'); + expect(stageVariant(600)).toBe('desktop'); + }); + + it('keeps every camera above the horizon and within the orbit limit', () => { + const lowest = + RIGHT_ANGLE_DEG - (SCENE_OPTIONS.camera?.maxPolarAngle ?? 0) * DEGREES_PER_RADIAN; + VIEWS.forEach((view) => + eachVariant((variant) => expect(elevation(view, variant), view).toBeGreaterThan(lowest)), + ); + }); + + it('looks down on the farm from the south-southwest at about 45 degrees', () => { + eachVariant((variant) => { + const [x, , z] = CAMERA_VIEWS.farmAerial.direction[variant]; + expect(x).toBeLessThan(0); + expect(z).toBeGreaterThan(Math.abs(x)); + expect(elevation('farmAerial', variant)).toBeCloseTo(AERIAL_DEG, WITHIN_FIVE_DEGREES); + }); + }); + + it('looks at the hero turbine from upwind and to the south, low', () => { + (['turbineTall', 'rotorQuarter'] as const).forEach((view) => + eachVariant((variant) => { + const [x, , z] = CAMERA_VIEWS[view].direction[variant]; + expect(x, view).toBeLessThan(0); + expect(z, view).toBeGreaterThan(0); + expect(elevation(view, variant), view).toBeLessThan(LOW_VIEW_MAX_DEG); + }), + ); + }); + + it('looks into the nacelle opening from the +z side and above', () => { + eachVariant((variant) => { + const [x, , z] = CAMERA_VIEWS.nacelleCutaway.direction[variant]; + expect(z).toBeGreaterThan(Math.abs(x)); + expect(elevation('nacelleCutaway', variant)).toBeGreaterThan(OPENING_MIN_DEG); + }); + }); + + it('looks along the rows from the south at about 25 degrees for the wakes', () => { + eachVariant((variant) => { + const [x, , z] = CAMERA_VIEWS.wakeStreaks.direction[variant]; + expect(z).toBeGreaterThan(Math.abs(x)); + expect(elevation('wakeStreaks', variant)).toBeCloseTo(WAKE_DEG, WITHIN_FIVE_DEGREES); + }); + }); + + it('looks down on the substation from the south-east at about 55 degrees', () => { + eachVariant((variant) => { + const [x, , z] = CAMERA_VIEWS.gridSubstation.direction[variant]; + expect(x).toBeGreaterThan(0); + expect(z).toBeGreaterThan(0); + expect(elevation('gridSubstation', variant)).toBeCloseTo(GRID_DEG, WITHIN_FIVE_DEGREES); + }); + }); +}); diff --git a/explainers/wind-farm/src/scene/controller.dom.test.ts b/explainers/wind-farm/src/scene/controller.dom.test.ts new file mode 100644 index 00000000..0e9aca4b --- /dev/null +++ b/explainers/wind-farm/src/scene/controller.dom.test.ts @@ -0,0 +1,177 @@ +import { Vector3 } from 'three'; +import type { Box3, Object3D } from 'three'; +import { describe, expect, it, vi } from 'vitest'; +import { frameBox } from '@core/scene/frameBox'; +import type { CameraPose } from '@core/scene/frameBox'; +import type { FramingSlopes } from '@core/scene/lens'; +import { MaterialLibrary } from '@core/scene/materials'; +import { createSceneTextures } from '@core/scene/textures'; +import type { AssemblyState, CameraView, RegionId } from '../ids'; +import { createAssemblyState, createWindFarmStore } from '../state'; +import { createAssembly } from './assembly'; +import type { Assembly } from './assembly'; +import { CAMERA_VIEWS, STAGE_VARIANTS } from './cameraViews'; +import type { StageVariant } from './cameraViews'; +import { WindFarmController } from './controller'; +import type { WindFarmControllerDependencies } from './controller'; + +const FRAMING = { vertical: 0.2, horizontal: 0.27 }; +const STAGE_SLOPES: Readonly> = { + phone: { vertical: 0.75, horizontal: 0.38 }, + desktop: { vertical: 0.42, horizontal: 0.75 }, +}; +const STAGE_WIDTH_PX: Readonly> = { phone: 390, desktop: 835 }; +const DIRECTION_DIGITS = 6; +const VIEWS = Object.keys(CAMERA_VIEWS) as CameraView[]; + +function assemblyState(changes: Partial = {}): AssemblyState { + return { ...createAssemblyState(createWindFarmStore().getState()), ...changes }; +} + +function referenceAssembly(): Assembly { + const resources = { materials: new MaterialLibrary(), textures: createSceneTextures() }; + return createAssembly(resources, assemblyState()); +} + +function regionBox(region: RegionId): Box3 { + return referenceAssembly().region(region); +} + +function fakeShell(stageWidth: number = STAGE_WIDTH_PX.desktop) { + const rig = { + framing: () => FRAMING, + follow: vi.fn<(anchor: Object3D | null, mode?: string) => void>(), + setDistanceLimits: vi.fn(), + jumpTo: vi.fn<(pose: CameraPose) => void>(), + tweenTo: vi.fn<(pose: CameraPose) => void>(), + setBounds: vi.fn<(bounds: Box3, floor: number) => void>(), + camera: { position: new Vector3(0, 100, 600) }, + controls: { target: new Vector3(0, 100, 0) }, + }; + const scene = { add: vi.fn<(object: Object3D) => void>() }; + const shell = { + scene, + materials: new MaterialLibrary(), + textures: createSceneTextures(), + labels: { attach: vi.fn(), show: vi.fn() }, + rig, + viewport: { + renderer: { compileAsync: vi.fn(() => Promise.resolve()) }, + element: { clientWidth: stageWidth }, + }, + }; + return { rig, scene, shell: shell as unknown as WindFarmControllerDependencies }; +} + +function viewDirection(pose: CameraPose): number[] { + return roundAll(pose.position.clone().sub(pose.target).normalize().toArray()); +} + +function roundAll(values: readonly number[]): number[] { + return values.map((value) => Number(value.toFixed(DIRECTION_DIGITS))); +} + +describe('wind farm controller', () => { + it('adds the assembly, attaches its labels and bounds the camera by the shown scene', () => { + const { rig, scene, shell } = fakeShell(); + new WindFarmController(shell).build(assemblyState()); + expect(scene.add).toHaveBeenCalledTimes(1); + expect(shell.labels.attach).toHaveBeenCalledTimes(1); + const [bounds, floor] = rig.setBounds.mock.lastCall ?? []; + expect(bounds).toEqual(regionBox('farm')); + expect(floor).toBe(bounds?.min.y); + }); + + it('rebounds the camera only when the scene swaps', () => { + const { rig, shell } = fakeShell(); + const controller = new WindFarmController(shell); + controller.build(assemblyState()); + controller.setState(assemblyState()); + expect(rig.setBounds).toHaveBeenCalledTimes(1); + controller.setState(assemblyState({ scene: 'turbine' })); + expect(rig.setBounds).toHaveBeenCalledTimes(2); + expect(rig.setBounds.mock.lastCall?.[0]).toEqual(regionBox('turbine')); + }); + + it('frames nothing before the assembly exists', () => { + const { rig, shell } = fakeShell(); + new WindFarmController(shell).views.frame('farmAerial', false); + expect(rig.jumpTo).not.toHaveBeenCalled(); + }); + + it.each(STAGE_VARIANTS)('frames every view on its region for a %s stage', (variant) => { + const { rig, shell } = fakeShell(STAGE_WIDTH_PX[variant]); + const controller = new WindFarmController(shell); + controller.build(assemblyState()); + for (const view of VIEWS) { + const spec = CAMERA_VIEWS[view]; + controller.views.frame(view, false); + const pose = rig.jumpTo.mock.lastCall?.[0]; + expect(pose?.target).toEqual(regionBox(spec.region).getCenter(new Vector3())); + const direction = new Vector3(...spec.direction[variant]).normalize(); + expect(pose && viewDirection(pose)).toEqual(roundAll(direction.toArray())); + expect(rig.setDistanceLimits).toHaveBeenLastCalledWith(spec.distance); + } + }); + + it('turns the close-up views with the yaw pivot and keeps the rest still', () => { + const { rig, scene, shell } = fakeShell(); + const controller = new WindFarmController(shell); + controller.build(assemblyState()); + const root = scene.add.mock.lastCall?.[0]; + for (const view of VIEWS) { + controller.views.frame(view, false); + const [anchor, mode] = rig.follow.mock.lastCall ?? []; + if (CAMERA_VIEWS[view].follow) { + expect(anchor && root?.getObjectById(anchor.id), view).toBe(anchor); + expect(mode, view).toBe('heading'); + } else expect(anchor, view).toBeNull(); + } + }); + + it.each(STAGE_VARIANTS)('starts every view inside its zoom limits on a %s', (variant) => { + const direction = (view: CameraView) => + new Vector3(...CAMERA_VIEWS[view].direction[variant]).normalize(); + for (const view of VIEWS) { + const { region, margin, distance } = CAMERA_VIEWS[view]; + const pose = frameBox(regionBox(region), direction(view), STAGE_SLOPES[variant], margin); + const reach = pose.position.distanceTo(pose.target); + expect(reach, view).toBeGreaterThanOrEqual(distance?.min ?? 0); + expect(reach, view).toBeLessThanOrEqual(distance?.max ?? Infinity); + } + }); + + it('tweens when animated and honours an explicit variant', () => { + const { rig, shell } = fakeShell(STAGE_WIDTH_PX.phone); + const controller = new WindFarmController(shell); + controller.build(assemblyState()); + controller.views.frame('gridSubstation', true, 'desktop'); + const pose = rig.tweenTo.mock.lastCall?.[0]; + const desktop = new Vector3(...CAMERA_VIEWS.gridSubstation.direction.desktop).normalize(); + expect(pose && viewDirection(pose)).toEqual(roundAll(desktop.toArray())); + expect(rig.jumpTo).not.toHaveBeenCalled(); + }); + + it('reports no motion without an assembly and removes it on dispose', () => { + const { scene, shell } = fakeShell(); + const controller = new WindFarmController(shell); + expect(controller.update(1)).toBe(false); + controller.build(assemblyState()); + const removed = vi.spyOn(scene.add.mock.lastCall?.[0] as Object3D, 'removeFromParent'); + controller.dispose(); + expect(removed).toHaveBeenCalledTimes(1); + expect(controller.update(1)).toBe(false); + }); + + it('rebuilds without leaving the previous assembly behind', () => { + const { rig, scene, shell } = fakeShell(); + const controller = new WindFarmController(shell); + controller.build(assemblyState()); + const first = scene.add.mock.lastCall?.[0]; + const removed = vi.spyOn(first as Object3D, 'removeFromParent'); + controller.build(assemblyState()); + expect(removed).toHaveBeenCalledTimes(1); + expect(scene.add).toHaveBeenCalledTimes(2); + expect(rig.setBounds).toHaveBeenCalledTimes(2); + }); +}); From d267db1fe37090ce80ace717156251d21eb3d08f Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:18:13 +0200 Subject: [PATCH 017/132] feat(wind-farm): add the wind shear and scale comparisons --- explainers/wind-farm/src/model/index.ts | 2 ++ explainers/wind-farm/src/model/scale.test.ts | 28 ++++++++++++++++++ explainers/wind-farm/src/model/scale.ts | 31 ++++++++++++++++++++ explainers/wind-farm/src/model/shear.test.ts | 24 +++++++++++++++ explainers/wind-farm/src/model/shear.ts | 5 ++++ 5 files changed, 90 insertions(+) create mode 100644 explainers/wind-farm/src/model/scale.test.ts create mode 100644 explainers/wind-farm/src/model/scale.ts create mode 100644 explainers/wind-farm/src/model/shear.test.ts create mode 100644 explainers/wind-farm/src/model/shear.ts diff --git a/explainers/wind-farm/src/model/index.ts b/explainers/wind-farm/src/model/index.ts index 745b20bf..537a3e82 100644 --- a/explainers/wind-farm/src/model/index.ts +++ b/explainers/wind-farm/src/model/index.ts @@ -6,3 +6,5 @@ export * from './control'; export * from './power'; export * from './wakes'; export * from './energy'; +export * from './shear'; +export * from './scale'; diff --git a/explainers/wind-farm/src/model/scale.test.ts b/explainers/wind-farm/src/model/scale.test.ts new file mode 100644 index 00000000..0d9e76fe --- /dev/null +++ b/explainers/wind-farm/src/model/scale.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { MAX_RPM } from './constants'; +import { builtHectares, farmRatedMw, projectKm2, sweptPitches, turnSeconds } from './scale'; + +describe('scale', () => { + it('sweeps about 2.47 football pitches with one rotor', () => { + expect(sweptPitches()).toBeCloseTo(2.47, 2); + }); + + it('rates the 27 turbines at 113.4 MW', () => { + expect(farmRatedMw()).toBeCloseTo(113.4, 9); + }); + + it('builds on about 34 ha inside a 38.6 km² project area', () => { + expect(builtHectares()).toBeCloseTo(34, 0); + expect(projectKm2()).toBeCloseTo(38.6, 1); + }); + + it('turns the rotor once every 5.8 s at full speed', () => { + expect(turnSeconds(MAX_RPM)).toBeCloseTo(5.8, 1); + expect(turnSeconds(6)).toBe(10); + }); + + it('never completes a turn while the rotor stands still', () => { + expect(turnSeconds(0)).toBe(Infinity); + expect(turnSeconds(-1)).toBe(Infinity); + }); +}); diff --git a/explainers/wind-farm/src/model/scale.ts b/explainers/wind-farm/src/model/scale.ts new file mode 100644 index 00000000..a18ac307 --- /dev/null +++ b/explainers/wind-farm/src/model/scale.ts @@ -0,0 +1,31 @@ +import { + BUILT_HA_PER_MW, + FARM_RATED_KW, + FOOTBALL_PITCH_M2, + PROJECT_HA_PER_MW, + SWEPT_AREA_M2, +} from './constants'; + +const KW_PER_MW = 1000; +const HECTARES_PER_KM2 = 100; +const SECONDS_PER_MINUTE = 60; + +export function sweptPitches(): number { + return SWEPT_AREA_M2 / FOOTBALL_PITCH_M2; +} + +export function farmRatedMw(): number { + return FARM_RATED_KW / KW_PER_MW; +} + +export function builtHectares(): number { + return BUILT_HA_PER_MW * farmRatedMw(); +} + +export function projectKm2(): number { + return (PROJECT_HA_PER_MW * farmRatedMw()) / HECTARES_PER_KM2; +} + +export function turnSeconds(rpm: number): number { + return rpm > 0 ? SECONDS_PER_MINUTE / rpm : Infinity; +} diff --git a/explainers/wind-farm/src/model/shear.test.ts b/explainers/wind-farm/src/model/shear.test.ts new file mode 100644 index 00000000..7621f84f --- /dev/null +++ b/explainers/wind-farm/src/model/shear.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { HUB_HEIGHT_M, TIP_HEIGHT_M } from './constants'; +import { SHEAR_HEIGHTS_M } from './layout'; +import { windAtHeight } from './shear'; + +describe('windAtHeight', () => { + it('keeps the hub wind at hub height', () => { + expect(windAtHeight(10, HUB_HEIGHT_M)).toBe(10); + }); + + it('blows about 10.8 m/s at the tip and 8.4 m/s at 30 m for 10 m/s at the hub', () => { + expect(windAtHeight(10, TIP_HEIGHT_M)).toBeCloseTo(10.8, 1); + expect(windAtHeight(10, 30)).toBeCloseTo(8.4, 1); + }); + + it('grows with height up the shear profile', () => { + const winds = SHEAR_HEIGHTS_M.map((height) => windAtHeight(10, height)); + winds.slice(1).forEach((wind, index) => expect(wind).toBeGreaterThan(winds[index])); + }); + + it('stays calm when the hub is calm', () => { + expect(windAtHeight(0, TIP_HEIGHT_M)).toBe(0); + }); +}); diff --git a/explainers/wind-farm/src/model/shear.ts b/explainers/wind-farm/src/model/shear.ts new file mode 100644 index 00000000..3589b917 --- /dev/null +++ b/explainers/wind-farm/src/model/shear.ts @@ -0,0 +1,5 @@ +import { HUB_HEIGHT_M, SHEAR_EXPONENT } from './constants'; + +export function windAtHeight(hubWind: number, heightM: number): number { + return hubWind * (heightM / HUB_HEIGHT_M) ** SHEAR_EXPONENT; +} From 46f555448d97aff8e2c4bdfa0bd2136025a7462a Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:20:31 +0200 Subject: [PATCH 018/132] feat(wind-farm): add the readout formats and the gauge readouts --- .../wind-farm/src/ui/format.dom.test.ts | 84 ++++++++++ explainers/wind-farm/src/ui/format.ts | 155 ++++++++++++++++++ explainers/wind-farm/src/ui/readouts.test.ts | 31 ++++ explainers/wind-farm/src/ui/readouts.ts | 33 ++++ 4 files changed, 303 insertions(+) create mode 100644 explainers/wind-farm/src/ui/format.dom.test.ts create mode 100644 explainers/wind-farm/src/ui/format.ts create mode 100644 explainers/wind-farm/src/ui/readouts.test.ts create mode 100644 explainers/wind-farm/src/ui/readouts.ts diff --git a/explainers/wind-farm/src/ui/format.dom.test.ts b/explainers/wind-farm/src/ui/format.dom.test.ts new file mode 100644 index 00000000..ee16f428 --- /dev/null +++ b/explainers/wind-farm/src/ui/format.dom.test.ts @@ -0,0 +1,84 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { GEAR_RATIO, MAX_RPM, generatorRpm } from '../model'; +import { + formatBrake, + formatDegrees, + formatFarmMw, + formatGearRatio, + formatGeneratorRpm, + formatHomes, + formatLossPercent, + formatMetres, + formatMwh, + formatOperatingState, + formatPercent, + formatRatedMw, + formatRpm, + formatSpacing, + formatSquareKm, + formatTipSpeed, + formatTurbineMw, + formatTurnTime, + formatVoltages, + formatWind, +} from './format'; +import { TEST_LOCALE, fill, initTestLocale } from './testing'; + +const { units, chapters } = TEST_LOCALE; + +beforeAll(() => initTestLocale()); + +describe('wind farm formats', () => { + it('shows wind to one decimal and rpm to one decimal', () => { + expect(formatWind(7.5)).toBe(fill(units.metresPerSecond, { value: '7.5' })); + expect(formatWind(12)).toBe(fill(units.metresPerSecond, { value: '12.0' })); + expect(formatRpm(MAX_RPM)).toBe(fill(units.rpm, { value: '10.4' })); + }); + + it('rounds the generator speed to the nearest 5 and the gear ratio to the nearest 10', () => { + expect(formatGeneratorRpm(generatorRpm(MAX_RPM))).toBe(fill(units.rpm, { value: '1,485' })); + expect(formatGearRatio(GEAR_RATIO)).toBe(fill(chapters.nacelle.gearValue, { ratio: '140' })); + expect(formatGearRatio(GEAR_RATIO)).toBe('about 1 to 140'); + }); + + it('shows the time for one turn, or that the rotor stands still', () => { + expect(formatTurnTime(MAX_RPM)).toBe(fill(units.seconds, { value: '5.8' })); + expect(formatTurnTime(0)).toBe(chapters.tower.stopped); + }); + + it('shows the tip speed in whole m/s and km/h to the nearest 10', () => { + expect(formatTipSpeed(MAX_RPM)).toBe(fill(units.speedPair, { ms: '82', kmh: '290' })); + }); + + it('shows one turbine in hundredths of a megawatt and the farm in tenths', () => { + expect(formatTurbineMw(4200)).toBe(fill(units.mw, { value: '4.20' })); + expect(formatTurbineMw(2545)).toBe(fill(units.mw, { value: '2.55' })); + expect(formatFarmMw(113_400)).toBe(fill(units.mw, { value: '113.4' })); + expect(formatRatedMw(113.4)).toBe(fill(units.mw, { value: '113.4' })); + }); + + it('shows whole percents except the wake loss', () => { + expect(formatPercent(0.456)).toBe(fill(units.percent, { value: '46' })); + expect(formatLossPercent(0.0492)).toBe(fill(units.percent, { value: '4.9' })); + }); + + it('rounds energy, homes, land and pitch', () => { + expect(formatMwh(1234.6)).toBe(fill(units.mwh, { value: '1,235' })); + expect(formatHomes(1361)).toBe(fill(units.count, { value: '1,400' })); + expect(formatSquareKm(38.6)).toBe(fill(units.squareKm, { value: '39' })); + expect(formatDegrees(12.4)).toBe(fill(units.degrees, { value: '12' })); + expect(formatMetres(73.7, 1)).toBe(fill(units.metres, { value: '73.7' })); + }); + + it('spells out the spacing, the brake, the state and the voltages', () => { + expect(formatSpacing(7)).toBe( + fill(chapters.farm.spacingValue, { metres: '1,050', diameters: '7' }), + ); + expect(formatBrake(true)).toBe(chapters.nacelle.brakeValue.holding); + expect(formatBrake(false)).toBe(chapters.nacelle.brakeValue.released); + expect(formatOperatingState('parked')).toBe(TEST_LOCALE.readouts.state.parked); + expect(formatVoltages()).toBe( + fill(chapters.grid.voltagesValue, { generator: '800', collector: '36', grid: '110' }), + ); + }); +}); diff --git a/explainers/wind-farm/src/ui/format.ts b/explainers/wind-farm/src/ui/format.ts new file mode 100644 index 00000000..01a2814a --- /dev/null +++ b/explainers/wind-farm/src/ui/format.ts @@ -0,0 +1,155 @@ +import { formatFixed, formatNumber } from '@core/format'; +import { t } from '@core/i18n'; +import type { OperatingStateId } from '../ids'; +import { + COLLECTOR_KV, + GENERATOR_VOLTS, + GRID_KV, + ROTOR_DIAMETER_M, + SWEPT_AREA_M2, + sweptPitches, + tipSpeed, + turnSeconds, +} from '../model'; + +const WHOLE = 0; +const TENTHS = 1; +const HUNDREDTHS = 2; +const PERCENT = 100; +const KW_PER_MW = 1000; +const KMH_PER_MS = 3.6; +const KMH_STEP = 10; +const GENERATOR_RPM_STEP = 5; +const GEAR_RATIO_STEP = 10; +const HOMES_STEP = 100; + +function unit(key: string, value: string): string { + return t(`units.${key}`, { value }); +} + +function roundTo(value: number, step: number): number { + return Math.round(value / step) * step; +} + +function whole(value: number): string { + return formatNumber(Math.round(value)); +} + +export function formatWind(metresPerSecond: number): string { + return unit('metresPerSecond', formatFixed(metresPerSecond, TENTHS)); +} + +export function formatRpm(rpm: number): string { + return unit('rpm', formatFixed(rpm, TENTHS)); +} + +export function formatGeneratorRpm(rpm: number): string { + return unit('rpm', formatNumber(roundTo(rpm, GENERATOR_RPM_STEP))); +} + +export function formatTurnTime(rpm: number): string { + if (rpm <= 0) return t('chapters.tower.stopped'); + return unit('seconds', formatFixed(turnSeconds(rpm), TENTHS)); +} + +export function formatTipSpeed(rpm: number): string { + const metresPerSecond = tipSpeed(rpm); + return t('units.speedPair', { + ms: whole(metresPerSecond), + kmh: formatNumber(roundTo(metresPerSecond * KMH_PER_MS, KMH_STEP)), + }); +} + +export function formatTurbineMw(kw: number): string { + return unit('mw', formatFixed(kw / KW_PER_MW, HUNDREDTHS)); +} + +export function formatFarmMw(kw: number): string { + return unit('mw', formatFixed(kw / KW_PER_MW, TENTHS)); +} + +export function formatRatedMw(mw: number): string { + return unit('mw', formatFixed(mw, TENTHS)); +} + +export function formatMwh(mwh: number): string { + return unit('mwh', whole(mwh)); +} + +export function formatPercent(fraction: number): string { + return unit('percent', whole(fraction * PERCENT)); +} + +export function formatLossPercent(fraction: number): string { + return unit('percent', formatFixed(fraction * PERCENT, TENTHS)); +} + +export function formatDegrees(degrees: number): string { + return unit('degrees', whole(degrees)); +} + +export function formatMetres(metres: number, fractionDigits = WHOLE): string { + return unit('metres', formatFixed(metres, fractionDigits)); +} + +export function formatCount(count: number): string { + return unit('count', whole(count)); +} + +export function formatHomes(homes: number): string { + return unit('count', formatNumber(roundTo(homes, HOMES_STEP))); +} + +export function formatHectares(hectares: number): string { + return unit('hectares', whole(hectares)); +} + +export function formatSquareKm(squareKm: number): string { + return unit('squareKm', whole(squareKm)); +} + +export function formatVolts(volts: number): string { + return unit('volts', whole(volts)); +} + +export function formatGramsPerKwh(grams: number): string { + return unit('gramsPerKwh', formatFixed(grams, TENTHS)); +} + +export function formatMonths(months: number): string { + return unit('months', formatFixed(months, TENTHS)); +} + +export function formatSpacing(diameters: number): string { + return t('chapters.farm.spacingValue', { + metres: whole(diameters * ROTOR_DIAMETER_M), + diameters: formatNumber(diameters), + }); +} + +export function formatSweptArea(): string { + return t('chapters.tower.sweptValue', { + area: whole(SWEPT_AREA_M2), + pitches: formatFixed(sweptPitches(), TENTHS), + }); +} + +export function formatGearRatio(ratio: number): string { + return t('chapters.nacelle.gearValue', { ratio: formatNumber(roundTo(ratio, GEAR_RATIO_STEP)) }); +} + +export function formatBrake(braked: boolean): string { + return t(`chapters.nacelle.brakeValue.${braked ? 'holding' : 'released'}`); +} + +export function formatOperatingState(state: OperatingStateId): string { + return t(`readouts.state.${state}`); +} + +export function formatVoltages(): string { + return t('chapters.grid.voltagesValue', { + generator: whole(GENERATOR_VOLTS), + collector: whole(COLLECTOR_KV), + grid: whole(GRID_KV), + }); +} diff --git a/explainers/wind-farm/src/ui/readouts.test.ts b/explainers/wind-farm/src/ui/readouts.test.ts new file mode 100644 index 00000000..8cc88ff0 --- /dev/null +++ b/explainers/wind-farm/src/ui/readouts.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { FARM_RATED_KW, RATED_KW } from '../model'; +import { WIND_OVERRIDE_RANGE, createWindFarmStore, liveReading } from '../state'; +import { WIND_FARM_READOUTS } from './readouts'; + +const RATED_WIND = 12; +const HALF = 0.5; + +function meterOf(id: string) { + const meter = WIND_FARM_READOUTS.find((row) => row.id === id)?.meter; + if (!meter) throw new Error(`No meter on ${id}`); + return meter; +} + +describe('gauge readouts', () => { + it('shows the wind, the turbine power and the farm power', () => { + expect(WIND_FARM_READOUTS.map((row) => row.id)).toEqual(['wind', 'power', 'farm']); + WIND_FARM_READOUTS.forEach((row) => expect(row.labelKey).toBe(`readouts.${row.id}`)); + WIND_FARM_READOUTS.forEach((row) => expect(row.numeric).toBe(true)); + }); + + it('meters the wind against the slider range and the power against the ratings', () => { + const state = createWindFarmStore({ windOverride: RATED_WIND }).getState(); + const reading = liveReading(state); + expect(meterOf('wind').share(state)).toBeCloseTo(RATED_WIND / WIND_OVERRIDE_RANGE.max); + expect(meterOf('power').share(state)).toBeCloseTo(reading.heroKw / RATED_KW); + expect(meterOf('power').share(state)).toBeCloseTo(1); + expect(meterOf('farm').share(state)).toBeCloseTo(reading.farmKw / FARM_RATED_KW); + expect(meterOf('farm').share(state)).toBeGreaterThan(HALF); + }); +}); diff --git a/explainers/wind-farm/src/ui/readouts.ts b/explainers/wind-farm/src/ui/readouts.ts new file mode 100644 index 00000000..4ca1cb35 --- /dev/null +++ b/explainers/wind-farm/src/ui/readouts.ts @@ -0,0 +1,33 @@ +import type { Readout } from '@core/explainer'; +import { FARM_RATED_KW, RATED_KW } from '../model'; +import { WIND_OVERRIDE_RANGE, liveReading } from '../state'; +import type { WindFarmStoreState } from '../state'; +import { THEME } from '../theme'; +import { formatFarmMw, formatTurbineMw, formatWind } from './format'; + +export const WIND_FARM_READOUTS: readonly Readout[] = [ + { + id: 'wind', + labelKey: 'readouts.wind', + numeric: true, + value: (state) => formatWind(liveReading(state).wind), + meter: { + share: (state) => liveReading(state).wind / WIND_OVERRIDE_RANGE.max, + fill: THEME.wind, + }, + }, + { + id: 'power', + labelKey: 'readouts.power', + numeric: true, + value: (state) => formatTurbineMw(liveReading(state).heroKw), + meter: { share: (state) => liveReading(state).heroKw / RATED_KW, fill: THEME.afternoon }, + }, + { + id: 'farm', + labelKey: 'readouts.farm', + numeric: true, + value: (state) => formatFarmMw(liveReading(state).farmKw), + meter: { share: (state) => liveReading(state).farmKw / FARM_RATED_KW, fill: THEME.cable }, + }, +]; From f2dff89a285db85ab9643480e494df693d045fc1 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:20:31 +0200 Subject: [PATCH 019/132] feat(wind-farm): mount the chapter readouts and wind sliders --- explainers/wind-farm/src/ui/index.ts | 24 ++++ .../wind-farm/src/ui/mountCurveReadouts.ts | 33 ++++++ .../wind-farm/src/ui/mountFarmReadouts.ts | 22 ++++ .../wind-farm/src/ui/mountGridReadouts.ts | 36 ++++++ .../wind-farm/src/ui/mountNacelleReadouts.ts | 24 ++++ .../wind-farm/src/ui/mountTowerReadouts.ts | 29 +++++ .../wind-farm/src/ui/mountUi.dom.test.ts | 108 ++++++++++++++++++ .../wind-farm/src/ui/mountWakesReadouts.ts | 31 +++++ .../wind-farm/src/ui/mountWindOverride.ts | 22 ++++ 9 files changed, 329 insertions(+) create mode 100644 explainers/wind-farm/src/ui/index.ts create mode 100644 explainers/wind-farm/src/ui/mountCurveReadouts.ts create mode 100644 explainers/wind-farm/src/ui/mountFarmReadouts.ts create mode 100644 explainers/wind-farm/src/ui/mountGridReadouts.ts create mode 100644 explainers/wind-farm/src/ui/mountNacelleReadouts.ts create mode 100644 explainers/wind-farm/src/ui/mountTowerReadouts.ts create mode 100644 explainers/wind-farm/src/ui/mountUi.dom.test.ts create mode 100644 explainers/wind-farm/src/ui/mountWakesReadouts.ts create mode 100644 explainers/wind-farm/src/ui/mountWindOverride.ts diff --git a/explainers/wind-farm/src/ui/index.ts b/explainers/wind-farm/src/ui/index.ts new file mode 100644 index 00000000..c88360ae --- /dev/null +++ b/explainers/wind-farm/src/ui/index.ts @@ -0,0 +1,24 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import type { WindFarmStore } from '../state'; +import { mountCurveReadouts } from './mountCurveReadouts'; +import { mountFarmReadouts } from './mountFarmReadouts'; +import { mountGridReadouts } from './mountGridReadouts'; +import { mountNacelleReadouts } from './mountNacelleReadouts'; +import { mountTowerReadouts } from './mountTowerReadouts'; +import { mountWakesReadouts } from './mountWakesReadouts'; + +export { CHAPTER_ACTIONS } from './actions'; +export { VIEW_TOGGLES, WIND_FARM_CHOICES } from './dock'; +export { WIND_FARM_READOUTS } from './readouts'; + +export function mountWindFarmUi(root: Document, store: WindFarmStore): Disposer { + return disposeAll([ + mountFarmReadouts(root, store), + mountTowerReadouts(root, store), + mountNacelleReadouts(root, store), + mountCurveReadouts(root, store), + mountWakesReadouts(root, store), + mountGridReadouts(root, store), + ]); +} diff --git a/explainers/wind-farm/src/ui/mountCurveReadouts.ts b/explainers/wind-farm/src/ui/mountCurveReadouts.ts new file mode 100644 index 00000000..6a878fe4 --- /dev/null +++ b/explainers/wind-farm/src/ui/mountCurveReadouts.ts @@ -0,0 +1,33 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { betzShare, powerCoefficient, windPowerKw } from '../model'; +import { liveReading } from '../state'; +import type { LiveReading, WindFarmStore } from '../state'; +import { formatDegrees, formatOperatingState, formatPercent, formatTurbineMw } from './format'; +import { mountPowerCurve } from './powerCurveView'; +import { mountWindOverride } from './mountWindOverride'; + +const CURVE_WIND_INPUT = 'curve-wind'; + +function whenProducing(share: (wind: number) => number) { + return ({ wind, operating }: LiveReading): number => (operating.producing ? share(wind) : 0); +} + +const coefficientOf = whenProducing(powerCoefficient); +const betzShareOf = whenProducing(betzShare); + +export function mountCurveReadouts(root: Document, store: WindFarmStore): Disposer { + return disposeAll([ + mountWindOverride(root, store, CURVE_WIND_INPUT), + mountLiveReadouts(root, store, { + operatingState: (state) => formatOperatingState(liveReading(state).operating.state), + curvePower: (state) => formatTurbineMw(liveReading(state).heroKw), + windPower: (state) => formatTurbineMw(windPowerKw(liveReading(state).wind)), + powerCoefficient: (state) => formatPercent(coefficientOf(liveReading(state))), + betzShare: (state) => formatPercent(betzShareOf(liveReading(state))), + curvePitch: (state) => formatDegrees(liveReading(state).operating.pitchDeg), + }), + mountPowerCurve(root, store), + ]); +} diff --git a/explainers/wind-farm/src/ui/mountFarmReadouts.ts b/explainers/wind-farm/src/ui/mountFarmReadouts.ts new file mode 100644 index 00000000..872c9cc7 --- /dev/null +++ b/explainers/wind-farm/src/ui/mountFarmReadouts.ts @@ -0,0 +1,22 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { ACROSS_SPACING_D, TURBINE_COUNT, builtHectares, farmRatedMw, projectKm2 } from '../model'; +import type { WindFarmStore } from '../state'; +import { + formatCount, + formatHectares, + formatRatedMw, + formatSpacing, + formatSquareKm, +} from './format'; + +export function mountFarmReadouts(root: Document, store: WindFarmStore): Disposer { + return mountLiveReadouts(root, store, { + turbineCount: () => formatCount(TURBINE_COUNT), + farmRated: () => formatRatedMw(farmRatedMw()), + rowSpacing: (state) => formatSpacing(state.spacing), + acrossSpacing: () => formatSpacing(ACROSS_SPACING_D), + builtLand: () => formatHectares(builtHectares()), + projectArea: () => formatSquareKm(projectKm2()), + }); +} diff --git a/explainers/wind-farm/src/ui/mountGridReadouts.ts b/explainers/wind-farm/src/ui/mountGridReadouts.ts new file mode 100644 index 00000000..a9ede3b6 --- /dev/null +++ b/explainers/wind-farm/src/ui/mountGridReadouts.ts @@ -0,0 +1,36 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { + ANNUAL_MWH_PER_TURBINE, + CARBON_G_PER_KWH, + PAYBACK_MONTHS, + annualHomesPerTurbine, + dayCapacityFactor, + energyTodayMwh, + homesNow, +} from '../model'; +import { liveReading } from '../state'; +import type { WindFarmStore } from '../state'; +import { + formatFarmMw, + formatGramsPerKwh, + formatHomes, + formatMonths, + formatMwh, + formatPercent, + formatVoltages, +} from './format'; + +export function mountGridReadouts(root: Document, store: WindFarmStore): Disposer { + return mountLiveReadouts(root, store, { + farmOutput: (state) => formatFarmMw(liveReading(state).farmKw), + energyToday: (state) => formatMwh(energyTodayMwh(state.phase, state.siteWind, state.spacing)), + homes: (state) => formatHomes(homesNow(liveReading(state).farmKw)), + capacityFactor: (state) => formatPercent(dayCapacityFactor(state.siteWind, state.spacing)), + voltages: () => formatVoltages(), + annualEnergy: () => formatMwh(ANNUAL_MWH_PER_TURBINE), + annualHomes: () => formatHomes(annualHomesPerTurbine()), + carbon: () => formatGramsPerKwh(CARBON_G_PER_KWH), + payback: () => formatMonths(PAYBACK_MONTHS), + }); +} diff --git a/explainers/wind-farm/src/ui/mountNacelleReadouts.ts b/explainers/wind-farm/src/ui/mountNacelleReadouts.ts new file mode 100644 index 00000000..53400807 --- /dev/null +++ b/explainers/wind-farm/src/ui/mountNacelleReadouts.ts @@ -0,0 +1,24 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { GEAR_RATIO, GENERATOR_VOLTS, generatorRpm } from '../model'; +import { liveReading } from '../state'; +import type { WindFarmStore } from '../state'; +import { + formatBrake, + formatDegrees, + formatGearRatio, + formatGeneratorRpm, + formatRpm, + formatVolts, +} from './format'; + +export function mountNacelleReadouts(root: Document, store: WindFarmStore): Disposer { + return mountLiveReadouts(root, store, { + shaftRpm: (state) => formatRpm(liveReading(state).operating.rpm), + generatorRpm: (state) => formatGeneratorRpm(generatorRpm(liveReading(state).operating.rpm)), + gearRatio: () => formatGearRatio(GEAR_RATIO), + pitchAngle: (state) => formatDegrees(liveReading(state).operating.pitchDeg), + brake: (state) => formatBrake(liveReading(state).operating.braked), + generatorVolts: () => formatVolts(GENERATOR_VOLTS), + }); +} diff --git a/explainers/wind-farm/src/ui/mountTowerReadouts.ts b/explainers/wind-farm/src/ui/mountTowerReadouts.ts new file mode 100644 index 00000000..dfffeafe --- /dev/null +++ b/explainers/wind-farm/src/ui/mountTowerReadouts.ts @@ -0,0 +1,29 @@ +import { disposeAll } from '@core/ui/disposers'; +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { BLADE_LENGTH_M, TIP_HEIGHT_M } from '../model'; +import { liveReading } from '../state'; +import type { WindFarmStore, WindFarmStoreState } from '../state'; +import { formatMetres, formatRpm, formatSweptArea, formatTipSpeed, formatTurnTime } from './format'; +import { mountWindOverride } from './mountWindOverride'; + +const TOWER_WIND_INPUT = 'tower-wind'; +const BLADE_DIGITS = 1; + +function rpmOf(state: WindFarmStoreState): number { + return liveReading(state).operating.rpm; +} + +export function mountTowerReadouts(root: Document, store: WindFarmStore): Disposer { + return disposeAll([ + mountWindOverride(root, store, TOWER_WIND_INPUT), + mountLiveReadouts(root, store, { + rotorRpm: (state) => formatRpm(rpmOf(state)), + turnTime: (state) => formatTurnTime(rpmOf(state)), + tipSpeed: (state) => formatTipSpeed(rpmOf(state)), + tipHeight: () => formatMetres(TIP_HEIGHT_M), + bladeLength: () => formatMetres(BLADE_LENGTH_M, BLADE_DIGITS), + sweptArea: () => formatSweptArea(), + }), + ]); +} diff --git a/explainers/wind-farm/src/ui/mountUi.dom.test.ts b/explainers/wind-farm/src/ui/mountUi.dom.test.ts new file mode 100644 index 00000000..90507987 --- /dev/null +++ b/explainers/wind-farm/src/ui/mountUi.dom.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { mountActions } from '@core/ui/actions'; +import { MAX_RPM, RATED_WIND_MS, WIND_PRESETS } from '../model'; +import { createWindFarmStore } from '../state'; +import type { WindFarmStore } from '../state'; +import { CHAPTER_ACTIONS } from './actions'; +import { CHAPTER_FIXTURE, TEST_LOCALE, fill, initTestLocale, isFilled } from './testing'; +import { mountWindFarmUi } from '.'; + +const { units, chapters, readouts } = TEST_LOCALE; +const NOON = 720; + +function readout(id: string): string | null | undefined { + return document.querySelector(`[data-readout="${id}"]`)?.textContent; +} + +function click(action: string, value: string): void { + document.querySelector(`[data-action="${action}"][data-value="${value}"]`)?.click(); +} + +function windInput(id: string): HTMLInputElement { + const found = document.querySelector(`#${id}`); + if (!found) throw new Error(`No ${id} control`); + return found; +} + +function slide(id: string, value: number): void { + const input = windInput(id); + input.value = String(value); + input.dispatchEvent(new Event('input')); +} + +function output(id: string): string | null | undefined { + return document.querySelector(`output[for="${id}"]`)?.textContent; +} + +describe('chapter widgets', () => { + let store: WindFarmStore; + let dispose: () => void; + + beforeAll(() => initTestLocale()); + + beforeEach(() => { + document.body.innerHTML = CHAPTER_FIXTURE; + store = createWindFarmStore({ playing: false, phase: NOON }); + dispose = mountWindFarmUi(document, store); + mountActions(document, store, CHAPTER_ACTIONS); + }); + + afterEach(() => dispose()); + + it('fills every readout with a finished text', () => { + const texts = [...document.querySelectorAll('[data-readout]')].map((node) => node.textContent); + expect(texts.length).toBeGreaterThan(0); + texts.forEach((text) => expect(isFilled(text), text ?? '').toBe(true)); + }); + + it('pins the wind from either slider and shows it in both', () => { + slide('tower-wind', RATED_WIND_MS); + expect(store.getState().windOverride).toBe(RATED_WIND_MS); + expect(output('tower-wind')).toBe(fill(units.metresPerSecond, { value: '12.0' })); + expect(output('curve-wind')).toBe(fill(units.metresPerSecond, { value: '12.0' })); + expect(windInput('curve-wind').value).toBe(String(RATED_WIND_MS)); + expect(readout('rotorRpm')).toBe(fill(units.rpm, { value: '10.4' })); + expect(readout('generatorRpm')).toBe(fill(units.rpm, { value: '1,485' })); + expect(readout('curvePower')).toBe(fill(units.mw, { value: '4.20' })); + expect(readout('operatingState')).toBe(readouts.state.full); + }); + + it('pins the wind from the power curve slider too', () => { + slide('curve-wind', WIND_PRESETS.cutIn); + expect(store.getState().windOverride).toBe(WIND_PRESETS.cutIn); + expect(output('curve-wind')).toBe(fill(units.metresPerSecond, { value: '3.0' })); + expect(output('tower-wind')).toBe(output('curve-wind')); + expect(readout('operatingState')).toBe(readouts.state.partial); + }); + + it('parks the rotor on the cut-out chip and hands the wind back to the day', () => { + click('windAt', 'cutOut'); + expect(store.getState().windOverride).toBe(WIND_PRESETS.cutOut); + expect(readout('operatingState')).toBe(readouts.state.parked); + expect(readout('turnTime')).toBe(chapters.tower.stopped); + expect(readout('curvePower')).toBe(fill(units.mw, { value: '0.00' })); + click('windAt', 'day'); + expect(store.getState().windOverride).toBeNull(); + expect(output('tower-wind')).not.toBe(fill(units.metresPerSecond, { value: '25.0' })); + }); + + it('follows the spacing, the site and the nacelle chips', () => { + click('spacing', '5'); + expect(readout('wakeSpacing')).toBe( + fill(chapters.farm.spacingValue, { metres: '750', diameters: '5' }), + ); + expect(readout('rowSpacing')).toBe(readout('wakeSpacing')); + const typicalCapacity = readout('capacityFactor'); + click('siteWind', 'windy'); + expect(store.getState().siteWind).toBe('windy'); + expect(readout('capacityFactor')).not.toBe(typicalCapacity); + click('nacelle', 'open'); + expect(store.getState().view.cutaway).toBe(true); + }); + + it('shows the turning speed at rated wind across the tower and the nacelle', () => { + click('windAt', 'rated'); + expect(readout('shaftRpm')).toBe(readout('rotorRpm')); + expect(readout('shaftRpm')).toBe(fill(units.rpm, { value: String(MAX_RPM) })); + }); +}); diff --git a/explainers/wind-farm/src/ui/mountWakesReadouts.ts b/explainers/wind-farm/src/ui/mountWakesReadouts.ts new file mode 100644 index 00000000..31ac21b8 --- /dev/null +++ b/explainers/wind-farm/src/ui/mountWakesReadouts.ts @@ -0,0 +1,31 @@ +import type { Disposer } from '@core/ui/disposers'; +import { mountLiveReadouts } from '@core/ui/liveReadouts'; +import { + HUB_HEIGHT_M, + ROTOR_RADIUS_M, + TIP_HEIGHT_M, + centrelineDeficit, + wakeLoss, + windAtHeight, +} from '../model'; +import { liveReading } from '../state'; +import type { WindFarmStore, WindFarmStoreState } from '../state'; +import { formatLossPercent, formatPercent, formatSpacing, formatWind } from './format'; + +const BOTTOM_TIP_HEIGHT_M = HUB_HEIGHT_M - ROTOR_RADIUS_M; + +function farmWakeLoss(state: WindFarmStoreState): number { + const { wind, fromDeg, operating } = liveReading(state); + return operating.producing ? wakeLoss(wind, fromDeg, state.spacing) : 0; +} + +export function mountWakesReadouts(root: Document, store: WindFarmStore): Disposer { + return mountLiveReadouts(root, store, { + wakeSpacing: (state) => formatSpacing(state.spacing), + wakeDeficit: (state) => + formatPercent(centrelineDeficit(liveReading(state).thrust, state.spacing)), + wakeLoss: (state) => formatLossPercent(farmWakeLoss(state)), + windTop: (state) => formatWind(windAtHeight(liveReading(state).wind, TIP_HEIGHT_M)), + windBottom: (state) => formatWind(windAtHeight(liveReading(state).wind, BOTTOM_TIP_HEIGHT_M)), + }); +} diff --git a/explainers/wind-farm/src/ui/mountWindOverride.ts b/explainers/wind-farm/src/ui/mountWindOverride.ts new file mode 100644 index 00000000..d9826fab --- /dev/null +++ b/explainers/wind-farm/src/ui/mountWindOverride.ts @@ -0,0 +1,22 @@ +import type { Disposer } from '@core/ui/disposers'; +import { requireElement } from '@core/ui/dom'; +import { mountRangeWidget } from '@core/ui/rangeWidget'; +import { WIND_OVERRIDE_RANGE, liveWind } from '../state'; +import type { WindFarmStore } from '../state'; +import { formatWind } from './format'; + +const WIND_OVERRIDE_CONTROL = 'wind-override'; + +type SelectedWind = readonly [wind: number]; + +export function mountWindOverride(root: Document, store: WindFarmStore, inputId: string): Disposer { + const input = requireElement(root, `#${inputId}`); + return mountRangeWidget(input.parentElement ?? root, store, { + control: WIND_OVERRIDE_CONTROL, + range: WIND_OVERRIDE_RANGE, + select: (state): SelectedWind => [liveWind(state)], + value: ([wind]) => wind, + format: ([wind]) => formatWind(wind), + set: (state, wind) => state.setWindOverride(wind), + }); +} From 0af18b65fdaf829dbb724743af6af7a6a0bf6c66 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:26:27 +0200 Subject: [PATCH 020/132] feat(wind-farm): add the explainer entry --- explainers/wind-farm/src/index.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 explainers/wind-farm/src/index.ts diff --git a/explainers/wind-farm/src/index.ts b/explainers/wind-farm/src/index.ts new file mode 100644 index 00000000..97cdc8f5 --- /dev/null +++ b/explainers/wind-farm/src/index.ts @@ -0,0 +1,28 @@ +import './style.css'; +import { defineExplainer } from '@core/explainer'; +import { SCENE_OPTIONS, mountWindFarmScene } from './scene'; +import { PART_INFO } from './scene/partInfo'; +import { PRESETS, createWindFarmStore } from './state'; +import type { WindFarmStoreState } from './state'; +import { WIND_FARM_TIMELINE } from './timeline'; +import { + CHAPTER_ACTIONS, + VIEW_TOGGLES, + WIND_FARM_CHOICES, + WIND_FARM_READOUTS, + mountWindFarmUi, +} from './ui'; + +export default defineExplainer({ + id: 'wind-farm', + timeline: WIND_FARM_TIMELINE, + presets: PRESETS, + parts: PART_INFO, + createStore: () => createWindFarmStore(), + dock: { choices: WIND_FARM_CHOICES, toggles: VIEW_TOGGLES }, + readouts: WIND_FARM_READOUTS, + actions: CHAPTER_ACTIONS, + scene: SCENE_OPTIONS, + mountScene: mountWindFarmScene, + mountUi: mountWindFarmUi, +}); From 5abaa0403c6639d43296d41db36c42c6b970bfcc Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:35:42 +0200 Subject: [PATCH 021/132] fix(wind-farm): open the day at the farm chapter's start time --- explainers/wind-farm/src/state/store.test.ts | 4 ++++ explainers/wind-farm/src/state/store.ts | 3 ++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/explainers/wind-farm/src/state/store.test.ts b/explainers/wind-farm/src/state/store.test.ts index 16b5b4c3..d3f293e8 100644 --- a/explainers/wind-farm/src/state/store.test.ts +++ b/explainers/wind-farm/src/state/store.test.ts @@ -4,15 +4,19 @@ import { CHAPTER_CONTROL_DEFAULTS, DEFAULT_SITE_WIND, DEFAULT_VIEW, + START_PHASE, WIND_OVERRIDE_RANGE, createWindFarmStore, snapToRange, } from './store'; +import { PRESETS } from './presets'; describe('wind farm store', () => { it('starts on the farm chapter at noon on a typical site with every view shown', () => { const state = createWindFarmStore().getState(); expect(state.preset).toBe('farm'); + expect(state.phase).toBe(START_PHASE); + expect(START_PHASE).toBe(PRESETS.farm.startAt); expect(state.siteWind).toBe(DEFAULT_SITE_WIND); expect(state.spacing).toBe(DEFAULT_SPACING_D); expect(state.windOverride).toBeNull(); diff --git a/explainers/wind-farm/src/state/store.ts b/explainers/wind-farm/src/state/store.ts index 08527063..c57c3976 100644 --- a/explainers/wind-farm/src/state/store.ts +++ b/explainers/wind-farm/src/state/store.ts @@ -49,6 +49,7 @@ export const CHAPTER_CONTROL_DEFAULTS: ChapterControls = { const START_PRESET: PresetId = 'farm'; const START_SPEED = PRESETS[START_PRESET].speed ?? SPEED_RANGE.default; +export const START_PHASE = PRESETS[START_PRESET].startAt ?? 0; export function snapToRange(value: number, { min, max, step }: SteppedRange): number { return clamp(min + Math.round((value - min) / step) * step, min, max); @@ -79,6 +80,6 @@ export function createWindFarmStore(overrides: Partial = {}) }), presetState: presetFields, }, - overrides, + { phase: START_PHASE, ...overrides }, ); } From 88636644e54cd0e45b1d278b5b67abe67515f3f5 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 09:59:33 +0200 Subject: [PATCH 022/132] feat(wind-farm): write the English chapters and locale --- explainers/wind-farm/chapters.html | 529 +++++++++++++++++++++++++++ explainers/wind-farm/locales/en.json | 269 ++++++++++++++ 2 files changed, 798 insertions(+) create mode 100644 explainers/wind-farm/chapters.html create mode 100644 explainers/wind-farm/locales/en.json diff --git a/explainers/wind-farm/chapters.html b/explainers/wind-farm/chapters.html new file mode 100644 index 00000000..87326d79 --- /dev/null +++ b/explainers/wind-farm/chapters.html @@ -0,0 +1,529 @@ +
+

01

+

Twenty-seven turbines and one windy day

+

+ This is a wind farm: 27 turbines spread over rolling fields. Each one is a + Vestas V150, and at full power it makes 4.2 megawatts (MW). A megawatt is a + million watts, so together the farm can make about 113 MW. It is sized like Målarberget in + Sweden, a real farm of 27 of these turbines that has run since 2021. +

+

+ The turbines stand in rows across the prevailing wind, the + direction the wind most often blows from. Here that is the west. Engineers measure the gaps in + rotor diameters, written D, and one D here is 150 m. Rows sit 7 D apart, or 1,050 m, and + turbines in a row sit 4 D apart, or 600 m. Each row is shifted sideways, so no turbine stands + right behind one in the row in front. +

+

+ From above it looks crowded, but most of the land is still fields. Only about 0.3 hectares per + megawatt is actually built on, and most of that is access roads, not turbine + bases. A hectare is a square 100 m on each side. The power runs through + collector cables to a substation, where it heads for the grid. +

+

+ Målarberget's real layout is not published, so the one you see is this model's own. The model + also plays a made-up day of wind, on a loop. It is not an average day: it fits a calm night, a + breezy morning, a full-power afternoon and an evening storm into 24 hours. The clock runs + hundreds of times faster than real time, but every rotor turns at its real speed. Roads, cables + and wind arrows are drawn wider than real so you can see them from kilometres away, and the + light does not follow the clock. +

+
+
+
Turbines
+
27
+
+
+
Full power of the farm
+
113.4 MW
+
+
+
Between rows, along the wind
+
1,050 m, 7 D
+
+
+
Between turbines in a row
+
600 m, 4 D
+
+
+
Land built on
+
34 ha
+
+
+
Whole project area
+
39 km²
+
+
+
+ +
+

02

+

Blades that fly in circles

+

+ Up close, one turbine is huge. The steel tower lifts the hub 105 m above the + ground, and the rotor, the hub with its three blades, is 150 m across. At the + top of its turn, a blade tip reaches about 180 m, almost twice as high as Big Ben. Up there the + wind blows faster than near the ground, as chapter 5 shows. +

+

+ Each blade is about 74 m long, almost the wingspan of an Airbus A380, the + biggest airliner. It is made of carbon fibre and glass fibre set in epoxy, a hard plastic glue. + A blade is not a sail that the wind pushes. The wind flows over it as over a plane's wing, and + the curved shape creates lift, a force across the wind. Lift pulls each blade + round, and the rotor turns. +

+

+ At full speed the rotor makes only about 10 turns a minute, or 10 rpm (revolutions per minute). + That is one turn every 6 seconds, which looks lazy. But the tips travel a huge circle, so they + move at about 80 m/s, close to 300 km/h. Drag the wind slider to see the rotor speed up with the + wind, or pick Follow the day to give the wind back to the clock. In this model the tips move 8 + times faster than the wind until the rotor reaches its top speed. +

+

+ The circle the blades sweep is the swept area: about 17,700 m², the size of + about 2.5 football pitches. Only the wind that passes through this circle can turn the rotor. + The lines of air round the rotor are a drawing, since real air is invisible. The + transformer stands at the foot of the tower in this model, because where the + V150 keeps it is not published. For the same reason, the slight tilt of the rotor is not drawn. +

+
+
+ 12.5 m/s +
+ +
+
+
Rotor speed
+
10.4 rpm
+
+
+
One turn takes
+
5.8 s
+
+
+
+
+ + +
+
+
+
Blade tip speed
+
82 m/s, 290 km/h
+
+
+
Tip height
+
180 m
+
+
+
Blade length
+
74 m
+
+
+
Swept area
+
17,671 m², about 2.5 football pitches
+
+
+
+ +
+

03

+

Inside the nacelle

+

+ The long box behind the hub is the nacelle, 12.8 m long. Pick Cover off to look + inside. It holds the machines that turn the rotor's slow, strong spin into electricity. They all + stand on the bedplate, a heavy cast iron frame, and a + cooler on the roof carries their heat away. +

+

+ In the hub, each blade sits on a bearing so it can twist. Three + pitch cylinders, one per blade, twist them with oil pressure. The blade's angle + to the wind is its pitch, and it sets how much the wind can grab. Behind the + hub, the main shaft turns in its main bearing at the rotor's + own slow speed, about 10 rpm. +

+

+ A generator needs to spin fast, so a gearbox sits in between. It has two + planetary stages, where small gears circle round a central one, and a helical stage with slanted + teeth. Together they make the shaft about 140 times faster, up to about 1,485 rpm. The + generator is an induction generator, with copper windings and no magnets. All + its power, at 800 volts, passes through the converter, which reshapes it to + match the grid. +

+

+ On the shaft sits a disc brake. It is not what stops the turbine in a storm: + the blades do that by turning edge-on to the wind. The brake only holds the rotor once it is + slow, to park it. On top of the tower, yaw drives turn the whole nacelle to + face the wind. In this model it follows the wind exactly, because the V150's real turning speed + is not published. +

+
+ + +
+
+
+
Main shaft
+
10.4 rpm
+
+
+
Generator
+
1,485 rpm
+
+
+
Gear ratio
+
about 1 to 140
+
+
+
Blade pitch
+
4°
+
+
+
Disc brake
+
Released
+
+
+
Generator voltage
+
800 V
+
+
+
+ +
+

04

+

How much wind a turbine can catch

+

+ Below 3 m/s, the cut-in speed, the rotor waits. Above it, power climbs fast, + because the power in the wind grows with the speed cubed: speed times speed times speed. Double + the wind, and it carries eight times the power. So the turbine makes about 0.3 MW at 4 m/s, + about 2.5 MW at 8 m/s and its full 4.2 MW from about 12 m/s. Drag the wind slider and watch the + dot move along the curve. +

+

+ No turbine can catch all of it. To take energy, the rotor has to slow the air down, yet the air + must still flow out behind it. The physicist Albert Betz showed that even a perfect rotor can + take at most 59.3 % of the power. That is the Betz limit. This turbine's best + is just under half, at about 7 to 8 m/s, and the chart shows how close it comes. +

+

+ Above full power the wind brings more than the generator can take. So the pitch cylinders turn + the blades a little out of the wind, and most of it slips past. At 15 m/s the turbine keeps only + about 12 % of the power in the wind. The pitch angles shown above full power are this model's + own, since the V150's are not published. Above 20 m/s, power starts to ramp down. +

+

+ At the cut-out speed, about 22 to 25 m/s, the blades turn edge-on to the wind. + This is feathering: the rotor slows on its own, and then the disc brake parks + it. The turbine starts again only once the wind has dropped by about 2 m/s. In this model all 27 + turbines stop together at 24.5 m/s and restart at 22.5 m/s. The stop and the restart each take + 20 minutes of the clock, longer than real, so you can watch them. +

+
+
+ 8.0 m/s +
+ +
+
+
Turbine state
+
Partial load
+
+
+
Turbine output
+
2.55 MW
+
+
+
+
+ + + + + + +
+
+ +
+ Power in the wind, the Betz limit and this turbine's output, for winds from 0 to 30 m/s +
+
+
+
+
Power in the wind
+
5.54 MW
+
+
+
Share it catches
+
46 %
+
+
+
Share of the Betz limit
+
78 %
+
+
+
Blade pitch
+
0°
+
+
+
+ +
+

05

+

Why turbines stand so far apart

+

+ A turbine takes energy out of the wind, so the air behind it moves slower. This trail of slow, + stirred air is called a wake. Switch on Wakes (K) to see a plume behind every + turning rotor. Wakes are strongest in moderate wind, when the rotor holds back the air the + hardest. Above full power the blades let more wind slip past, and the wakes fade. +

+

+ This model draws wakes with a simple, widely used recipe, the Jensen model. It + treats each wake as a cone that widens and slowly fills back up with faster air. How fast it + widens is set by one number, 0.075 here, the usual value for land. At 8 m/s, a turbine 7 D + downwind still meets air about 13 % slower. Because power follows the wind cubed, that costs it + about a third of its power. +

+

+ Over a year, wakes cost a farm about 5 to 15 % of its energy. A Vestas example farm of 24 of + these turbines loses about 6 %. For a single wind direction, the loss can pass 30 %. That is why + rows usually stand 5 to 9 D apart along the wind and 3 to 5 D apart across it. Try 5, 7 and 9 D: + wider gaps lose less wind but need more land. +

+

+ Wind also grows with height, because the ground drags on the air near it. Switch on Airflow (F) + to see the profile: the top of the rotor meets faster wind than the bottom. The plumes and + arrows are drawings of invisible air. In this model the ground is flat, a stopped rotor leaves + no wake, and wakes that overlap add up in a simple way. +

+
+ + + +
+
+
+
Gap between rows
+
1,050 m, 7 D
+
+
+
Wake slowdown at the next row
+
13 %
+
+
+
Farm loss to wakes now
+
4.9 %
+
+
+
Wind at the top of the rotor
+
8.3 m/s
+
+
+
Wind at the bottom of the rotor
+
6.1 m/s
+
+
+
+ +
+

06

+

The road to the grid

+

+ Each generator makes power at 800 volts (V). After the converter, the turbine's own + transformer raises the voltage. Higher voltage carries the same power with less + current, so less of it is lost as heat in the cables. Switch on Cables (C) to follow the power + from the turbines to the grid. +

+

+ Collector cables at 36,000 volts, or 36 kilovolts (kV), link the turbines to + the substation. There another transformer raises the voltage to 110 kV for the + grid line. These voltages come from a Vestas example farm in Germany. +

+

+ At full power, one turbine makes 4.2 megawatt hours (MWh) every hour. But the wind is not always + strong, so over a year a turbine averages about 40 % of its full power. That share is the + capacity factor. In a year, one turbine makes about 14,700 MWh, enough for + about 1,400 US homes. The model's made-up day is windier than a year's average, so its capacity + factor comes out higher. +

+

+ Making a turbine costs energy and carbon too. Over its life, it adds about 7 g of CO2e for each + kilowatt hour (kWh) it makes, where CO2e counts every greenhouse gas as if it were carbon + dioxide. It pays back the energy used to build it in about 8 months. It is designed for 20 years + and often runs longer, though its blades are still hard to recycle. Pick a calm, typical or + windy site to see how much the everyday wind matters. +

+
+ + + +
+
+
+
Farm output now
+
113.4 MW
+
+
+
Energy made today
+
1,250 MWh
+
+
+
US homes this output could run
+
92,200
+
+
+
Capacity factor of this day
+
43 %
+
+
+
Voltage at each step
+
800 V, 36 kV, 110 kV
+
+
+
One turbine in a year
+
14,692 MWh
+
+
+
US homes one turbine supplies
+
1,400
+
+
+
Carbon footprint
+
7.3 g CO2e per kWh
+
+
+
Energy payback
+
7.6 months
+
+
+
diff --git a/explainers/wind-farm/locales/en.json b/explainers/wind-farm/locales/en.json new file mode 100644 index 00000000..0d0fba59 --- /dev/null +++ b/explainers/wind-farm/locales/en.json @@ -0,0 +1,269 @@ +{ + "meta": { + "title": "How a wind farm works", + "eyebrow": "Renewable energy", + "tagline": "Twenty-seven turbines and one windy day: how a blade shaped like a wing turns moving air into power for the grid.", + "description": "A 3D wind farm through one windy day: how blades catch the wind, what turns in the nacelle, why turbines stand far apart and how power reaches the grid.", + "summary": "The farm layout, the tower and blades, the gearbox and generator, the power curve and the Betz limit, wakes, and the road to the grid.", + "socialAlt": "A wind farm on rolling land with one turbine close up, beside the title How a wind farm works" + }, + "stage": { + "label": "Interactive wind farm model", + "gauge": "Live wind and power" + }, + "timeline": { + "label": "Time of day", + "phases": "Jump to a part of the day", + "speed": "Playback speed", + "value": "{{time}}", + "during": "{{time}}, {{phase}}", + "speedValue": "{{value}} h/min", + "speedFormat": "{{value}} hours of the day per minute", + "phase": { + "night": "Calm night", + "morning": "Morning breeze", + "afternoon": "Full power", + "storm": "Storm", + "evening": "Evening" + }, + "jump": { + "night": "Jump to the calm night", + "morning": "Jump to the morning breeze", + "afternoon": "Jump to full power", + "storm": "Jump to the storm", + "evening": "Jump to the evening" + } + }, + "controls": { + "label": "Model controls", + "siteWind": "Site wind", + "siteWindOptions": { + "calm": "Calm site", + "typical": "Typical site", + "windy": "Windy site" + }, + "spacingOptions": { + "5": "5 D", + "7": "7 D", + "9": "9 D" + }, + "nacelleOptions": { + "closed": "Cover on", + "open": "Cover off" + }, + "windAtOptions": { + "day": "Follow the day", + "cutIn": "Cut-in, 3 m/s", + "peak": "Best share, 8 m/s", + "rated": "Full power, 12 m/s", + "rampDown": "Ramping down, 23 m/s", + "cutOut": "Past cut-out, 25 m/s" + }, + "groups": { + "siteWind": "Site wind", + "spacing": "Gap between rows", + "nacelle": "Nacelle cover", + "windAt": "Wind speed" + }, + "views": { + "streamlines": { + "name": "Airflow", + "short": "Air", + "hint": "Airflow (F)" + }, + "wakes": { + "name": "Wakes", + "short": "Wakes", + "hint": "Wakes (K)" + }, + "cables": { + "name": "Cables", + "short": "Cables", + "hint": "Cables (C)" + }, + "labels": { + "name": "Labels", + "short": "Labels", + "hint": "Labels (L)" + } + } + }, + "readouts": { + "wind": "Wind", + "power": "One turbine", + "farm": "Whole farm", + "state": { + "idle": "Waiting for wind", + "partial": "Partial load", + "full": "Full power", + "rampDown": "Ramping down", + "stopping": "Feathering to stop", + "parked": "Parked", + "starting": "Starting up" + } + }, + "units": { + "metresPerSecond": "{{value}} m/s", + "speedPair": "{{ms}} m/s, {{kmh}} km/h", + "rpm": "{{value}} rpm", + "degrees": "{{value}}°", + "mw": "{{value}} MW", + "mwh": "{{value}} MWh", + "percent": "{{value}} %", + "metres": "{{value}} m", + "seconds": "{{value}} s", + "count": "{{value}}", + "hectares": "{{value}} ha", + "squareKm": "{{value}} km²", + "volts": "{{value}} V", + "gramsPerKwh": "{{value}} g CO2e per kWh", + "months": "{{value}} months" + }, + "chapters": { + "farm": { + "count": "Turbines", + "rated": "Full power of the farm", + "rows": "Between rows, along the wind", + "across": "Between turbines in a row", + "spacingValue": "{{metres}} m, {{diameters}} D", + "built": "Land built on", + "project": "Whole project area" + }, + "tower": { + "wind": "Wind at hub height", + "rpm": "Rotor speed", + "turn": "One turn takes", + "stopped": "Rotor stopped", + "tip": "Blade tip speed", + "height": "Tip height", + "blade": "Blade length", + "swept": "Swept area", + "sweptValue": "{{area}} m², about {{pitches}} football pitches" + }, + "nacelle": { + "shaft": "Main shaft", + "generator": "Generator", + "gear": "Gear ratio", + "gearValue": "about 1 to {{ratio}}", + "pitch": "Blade pitch", + "brake": "Disc brake", + "brakeValue": { + "released": "Released", + "holding": "Holding the rotor" + }, + "volts": "Generator voltage" + }, + "curve": { + "wind": "Wind speed", + "state": "Turbine state", + "power": "Turbine output", + "caption": "Power in the wind, the Betz limit and this turbine's output, for winds from 0 to 30 m/s", + "axisWind": "Wind speed (m/s)", + "axisPower": "Power (MW)", + "lineWind": "Power in the wind", + "lineBetz": "Betz limit", + "lineTurbine": "This turbine", + "windPower": "Power in the wind", + "cp": "Share it catches", + "betz": "Share of the Betz limit", + "pitch": "Blade pitch" + }, + "wakes": { + "spacing": "Gap between rows", + "deficit": "Wake slowdown at the next row", + "loss": "Farm loss to wakes now", + "top": "Wind at the top of the rotor", + "bottom": "Wind at the bottom of the rotor" + }, + "grid": { + "output": "Farm output now", + "energy": "Energy made today", + "homes": "US homes this output could run", + "capacity": "Capacity factor of this day", + "voltages": "Voltage at each step", + "voltagesValue": "{{generator}} V, {{collector}} kV, {{grid}} kV", + "annual": "One turbine in a year", + "annualHomes": "US homes one turbine supplies", + "carbon": "Carbon footprint", + "payback": "Energy payback" + } + }, + "parts": { + "land": "Ground", + "foundation": "Foundation", + "tower": "Tower", + "transformer": "Transformer", + "towerCable": "Power cable", + "nacelle": "Nacelle", + "cooler": "Cooler", + "bedplate": "Bedplate", + "hub": "Hub", + "blades": "Blades", + "pitchCylinders": "Pitch cylinders", + "mainBearing": "Main bearing", + "mainShaft": "Main shaft", + "gearbox": "Gearbox", + "brakeDisc": "Disc brake", + "generator": "Generator", + "converter": "Converter", + "yawDrives": "Yaw drives", + "sweptArea": "Swept area", + "streamlinesGroup": "Airflow", + "heroWake": "Wake", + "farmLand": "Farmland", + "farmTurbines": "27 turbines", + "accessRoads": "Access roads", + "spacingMarker": "Row spacing", + "prevailingWind": "Prevailing wind", + "windArrows": "Wind", + "shearProfile": "Wind by height", + "wakePlumes": "Wakes", + "collectorCables": "Collector cables", + "substation": "Substation", + "gridLine": "Grid line" + }, + "sections": { + "farm": { + "title": "Twenty-seven turbines and one windy day", + "p1": "This is a wind farm: 27 turbines spread over rolling fields. Each one is a Vestas V150, and at full power it makes 4.2 megawatts (MW). A megawatt is a million watts, so together the farm can make about 113 MW. It is sized like Målarberget in Sweden, a real farm of 27 of these turbines that has run since 2021.", + "p2": "The turbines stand in rows across the prevailing wind, the direction the wind most often blows from. Here that is the west. Engineers measure the gaps in rotor diameters, written D, and one D here is 150 m. Rows sit 7 D apart, or 1,050 m, and turbines in a row sit 4 D apart, or 600 m. Each row is shifted sideways, so no turbine stands right behind one in the row in front.", + "p3": "From above it looks crowded, but most of the land is still fields. Only about 0.3 hectares per megawatt is actually built on, and most of that is access roads, not turbine bases. A hectare is a square 100 m on each side. The power runs through collector cables to a substation, where it heads for the grid.", + "p4": "Målarberget's real layout is not published, so the one you see is this model's own. The model also plays a made-up day of wind, on a loop. It is not an average day: it fits a calm night, a breezy morning, a full-power afternoon and an evening storm into 24 hours. The clock runs hundreds of times faster than real time, but every rotor turns at its real speed. Roads, cables and wind arrows are drawn wider than real so you can see them from kilometres away, and the light does not follow the clock." + }, + "tower": { + "title": "Blades that fly in circles", + "p1": "Up close, one turbine is huge. The steel tower lifts the hub 105 m above the ground, and the rotor, the hub with its three blades, is 150 m across. At the top of its turn, a blade tip reaches about 180 m, almost twice as high as Big Ben. Up there the wind blows faster than near the ground, as chapter 5 shows.", + "p2": "Each blade is about 74 m long, almost the wingspan of an Airbus A380, the biggest airliner. It is made of carbon fibre and glass fibre set in epoxy, a hard plastic glue. A blade is not a sail that the wind pushes. The wind flows over it as over a plane's wing, and the curved shape creates lift, a force across the wind. Lift pulls each blade round, and the rotor turns.", + "p3": "At full speed the rotor makes only about 10 turns a minute, or 10 rpm (revolutions per minute). That is one turn every 6 seconds, which looks lazy. But the tips travel a huge circle, so they move at about 80 m/s, close to 300 km/h. Drag the wind slider to see the rotor speed up with the wind, or pick Follow the day to give the wind back to the clock. In this model the tips move 8 times faster than the wind until the rotor reaches its top speed.", + "p4": "The circle the blades sweep is the swept area: about 17,700 m², the size of about 2.5 football pitches. Only the wind that passes through this circle can turn the rotor. The lines of air round the rotor are a drawing, since real air is invisible. The transformer stands at the foot of the tower in this model, because where the V150 keeps it is not published. For the same reason, the slight tilt of the rotor is not drawn." + }, + "nacelle": { + "title": "Inside the nacelle", + "p1": "The long box behind the hub is the nacelle, 12.8 m long. Pick Cover off to look inside. It holds the machines that turn the rotor's slow, strong spin into electricity. They all stand on the bedplate, a heavy cast iron frame, and a cooler on the roof carries their heat away.", + "p2": "In the hub, each blade sits on a bearing so it can twist. Three pitch cylinders, one per blade, twist them with oil pressure. The blade's angle to the wind is its pitch, and it sets how much the wind can grab. Behind the hub, the main shaft turns in its main bearing at the rotor's own slow speed, about 10 rpm.", + "p3": "A generator needs to spin fast, so a gearbox sits in between. It has two planetary stages, where small gears circle round a central one, and a helical stage with slanted teeth. Together they make the shaft about 140 times faster, up to about 1,485 rpm. The generator is an induction generator, with copper windings and no magnets. All its power, at 800 volts, passes through the converter, which reshapes it to match the grid.", + "p4": "On the shaft sits a disc brake. It is not what stops the turbine in a storm: the blades do that by turning edge-on to the wind. The brake only holds the rotor once it is slow, to park it. On top of the tower, yaw drives turn the whole nacelle to face the wind. In this model it follows the wind exactly, because the V150's real turning speed is not published." + }, + "curve": { + "title": "How much wind a turbine can catch", + "p1": "Below 3 m/s, the cut-in speed, the rotor waits. Above it, power climbs fast, because the power in the wind grows with the speed cubed: speed times speed times speed. Double the wind, and it carries eight times the power. So the turbine makes about 0.3 MW at 4 m/s, about 2.5 MW at 8 m/s and its full 4.2 MW from about 12 m/s. Drag the wind slider and watch the dot move along the curve.", + "p2": "No turbine can catch all of it. To take energy, the rotor has to slow the air down, yet the air must still flow out behind it. The physicist Albert Betz showed that even a perfect rotor can take at most 59.3 % of the power. That is the Betz limit. This turbine's best is just under half, at about 7 to 8 m/s, and the chart shows how close it comes.", + "p3": "Above full power the wind brings more than the generator can take. So the pitch cylinders turn the blades a little out of the wind, and most of it slips past. At 15 m/s the turbine keeps only about 12 % of the power in the wind. The pitch angles shown above full power are this model's own, since the V150's are not published. Above 20 m/s, power starts to ramp down.", + "p4": "At the cut-out speed, about 22 to 25 m/s, the blades turn edge-on to the wind. This is feathering: the rotor slows on its own, and then the disc brake parks it. The turbine starts again only once the wind has dropped by about 2 m/s. In this model all 27 turbines stop together at 24.5 m/s and restart at 22.5 m/s. The stop and the restart each take 20 minutes of the clock, longer than real, so you can watch them." + }, + "wakes": { + "title": "Why turbines stand so far apart", + "p1": "A turbine takes energy out of the wind, so the air behind it moves slower. This trail of slow, stirred air is called a wake. Switch on Wakes (K) to see a plume behind every turning rotor. Wakes are strongest in moderate wind, when the rotor holds back the air the hardest. Above full power the blades let more wind slip past, and the wakes fade.", + "p2": "This model draws wakes with a simple, widely used recipe, the Jensen model. It treats each wake as a cone that widens and slowly fills back up with faster air. How fast it widens is set by one number, 0.075 here, the usual value for land. At 8 m/s, a turbine 7 D downwind still meets air about 13 % slower. Because power follows the wind cubed, that costs it about a third of its power.", + "p3": "Over a year, wakes cost a farm about 5 to 15 % of its energy. A Vestas example farm of 24 of these turbines loses about 6 %. For a single wind direction, the loss can pass 30 %. That is why rows usually stand 5 to 9 D apart along the wind and 3 to 5 D apart across it. Try 5, 7 and 9 D: wider gaps lose less wind but need more land.", + "p4": "Wind also grows with height, because the ground drags on the air near it. Switch on Airflow (F) to see the profile: the top of the rotor meets faster wind than the bottom. The plumes and arrows are drawings of invisible air. In this model the ground is flat, a stopped rotor leaves no wake, and wakes that overlap add up in a simple way." + }, + "grid": { + "title": "The road to the grid", + "p1": "Each generator makes power at 800 volts (V). After the converter, the turbine's own transformer raises the voltage. Higher voltage carries the same power with less current, so less of it is lost as heat in the cables. Switch on Cables (C) to follow the power from the turbines to the grid.", + "p2": "Collector cables at 36,000 volts, or 36 kilovolts (kV), link the turbines to the substation. There another transformer raises the voltage to 110 kV for the grid line. These voltages come from a Vestas example farm in Germany.", + "p3": "At full power, one turbine makes 4.2 megawatt hours (MWh) every hour. But the wind is not always strong, so over a year a turbine averages about 40 % of its full power. That share is the capacity factor. In a year, one turbine makes about 14,700 MWh, enough for about 1,400 US homes. The model's made-up day is windier than a year's average, so its capacity factor comes out higher.", + "p4": "Making a turbine costs energy and carbon too. Over its life, it adds about 7 g of CO2e for each kilowatt hour (kWh) it makes, where CO2e counts every greenhouse gas as if it were carbon dioxide. It pays back the energy used to build it in about 8 months. It is designed for 20 years and often runs longer, though its blades are still hard to recycle. Pick a calm, typical or windy site to see how much the everyday wind matters." + } + } +} From 4e8313de6ef08872f0dfb55f66c42febbe167533 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:03:49 +0200 Subject: [PATCH 023/132] feat(wind-farm): add the Portuguese locale --- explainers/wind-farm/locales/pt.json | 269 +++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 explainers/wind-farm/locales/pt.json diff --git a/explainers/wind-farm/locales/pt.json b/explainers/wind-farm/locales/pt.json new file mode 100644 index 00000000..3f48ee87 --- /dev/null +++ b/explainers/wind-farm/locales/pt.json @@ -0,0 +1,269 @@ +{ + "meta": { + "title": "Como funciona um parque eólico", + "eyebrow": "Energia renovável", + "tagline": "Vinte e sete turbinas e um dia de muito vento: como uma pá com formato de asa transforma o movimento do ar em energia para a rede elétrica.", + "description": "Um parque eólico em 3D num dia de vento: como as pás captam o vento, o que gira na nacele, por que as turbinas ficam longe e como a energia chega à rede.", + "summary": "A disposição do parque, a torre e as pás, a caixa de engrenagens e o gerador, a curva de potência e o limite de Betz, as esteiras e o caminho até a rede.", + "socialAlt": "Um parque eólico em terreno ondulado, com uma turbina em primeiro plano, ao lado do título “Como funciona um parque eólico”" + }, + "stage": { + "label": "Modelo interativo de um parque eólico", + "gauge": "Vento e potência em tempo real" + }, + "timeline": { + "label": "Hora do dia", + "phases": "Ir para um momento do dia", + "speed": "Velocidade de reprodução", + "value": "{{time}}", + "during": "{{time}}, {{phase}}", + "speedValue": "{{value}} h/min", + "speedFormat": "{{value}} horas do dia por minuto", + "phase": { + "night": "Noite calma", + "morning": "Brisa da manhã", + "afternoon": "Potência máxima", + "storm": "Tempestade", + "evening": "Anoitecer" + }, + "jump": { + "night": "Ir para a noite calma", + "morning": "Ir para a brisa da manhã", + "afternoon": "Ir para a potência máxima", + "storm": "Ir para a tempestade", + "evening": "Ir para o anoitecer" + } + }, + "controls": { + "label": "Controles do modelo", + "siteWind": "Vento no local", + "siteWindOptions": { + "calm": "Local calmo", + "typical": "Local típico", + "windy": "Local ventoso" + }, + "spacingOptions": { + "5": "5 D", + "7": "7 D", + "9": "9 D" + }, + "nacelleOptions": { + "closed": "Com cobertura", + "open": "Sem cobertura" + }, + "windAtOptions": { + "day": "Seguir o dia", + "cutIn": "Partida, 3 m/s", + "peak": "Melhor aproveitamento, 8 m/s", + "rated": "Potência máxima, 12 m/s", + "rampDown": "Reduzindo, 23 m/s", + "cutOut": "Acima do corte, 25 m/s" + }, + "groups": { + "siteWind": "Vento no local", + "spacing": "Distância entre fileiras", + "nacelle": "Cobertura da nacele", + "windAt": "Velocidade do vento" + }, + "views": { + "streamlines": { + "name": "Fluxo de ar", + "short": "Ar", + "hint": "Fluxo de ar (F)" + }, + "wakes": { + "name": "Esteiras", + "short": "Esteiras", + "hint": "Esteiras (K)" + }, + "cables": { + "name": "Cabos", + "short": "Cabos", + "hint": "Cabos (C)" + }, + "labels": { + "name": "Rótulos", + "short": "Rótulos", + "hint": "Rótulos (L)" + } + } + }, + "readouts": { + "wind": "Vento", + "power": "Uma turbina", + "farm": "Parque inteiro", + "state": { + "idle": "Esperando o vento", + "partial": "Carga parcial", + "full": "Potência máxima", + "rampDown": "Reduzindo a potência", + "stopping": "Embandeirando para parar", + "parked": "Travada", + "starting": "Dando a partida" + } + }, + "units": { + "metresPerSecond": "{{value}} m/s", + "speedPair": "{{ms}} m/s, {{kmh}} km/h", + "rpm": "{{value}} rpm", + "degrees": "{{value}}°", + "mw": "{{value}} MW", + "mwh": "{{value}} MWh", + "percent": "{{value}} %", + "metres": "{{value}} m", + "seconds": "{{value}} s", + "count": "{{value}}", + "hectares": "{{value}} ha", + "squareKm": "{{value}} km²", + "volts": "{{value}} V", + "gramsPerKwh": "{{value}} g de CO2e por kWh", + "months": "{{value}} meses" + }, + "chapters": { + "farm": { + "count": "Turbinas", + "rated": "Potência máxima do parque", + "rows": "Entre fileiras, no sentido do vento", + "across": "Entre turbinas da mesma fileira", + "spacingValue": "{{metres}} m, {{diameters}} D", + "built": "Área construída", + "project": "Área total do projeto" + }, + "tower": { + "wind": "Vento na altura do cubo", + "rpm": "Velocidade do rotor", + "turn": "Uma volta leva", + "stopped": "Rotor parado", + "tip": "Velocidade na ponta da pá", + "height": "Altura da ponta", + "blade": "Comprimento da pá", + "swept": "Área varrida", + "sweptValue": "{{area}} m², cerca de {{pitches}} campos de futebol" + }, + "nacelle": { + "shaft": "Eixo principal", + "generator": "Gerador", + "gear": "Relação de transmissão", + "gearValue": "cerca de 1 para {{ratio}}", + "pitch": "Passo das pás", + "brake": "Freio a disco", + "brakeValue": { + "released": "Solto", + "holding": "Segurando o rotor" + }, + "volts": "Tensão do gerador" + }, + "curve": { + "wind": "Velocidade do vento", + "state": "Estado da turbina", + "power": "Produção da turbina", + "caption": "Potência do vento, limite de Betz e produção desta turbina, para ventos de 0 a 30 m/s", + "axisWind": "Velocidade do vento (m/s)", + "axisPower": "Potência (MW)", + "lineWind": "Potência do vento", + "lineBetz": "Limite de Betz", + "lineTurbine": "Esta turbina", + "windPower": "Potência do vento", + "cp": "Parcela captada", + "betz": "Parcela do limite de Betz", + "pitch": "Passo das pás" + }, + "wakes": { + "spacing": "Distância entre fileiras", + "deficit": "Perda de velocidade na fileira seguinte", + "loss": "Perda do parque com esteiras agora", + "top": "Vento no alto do rotor", + "bottom": "Vento na parte de baixo do rotor" + }, + "grid": { + "output": "Produção do parque agora", + "energy": "Energia gerada hoje", + "homes": "Casas nos EUA que essa produção abasteceria", + "capacity": "Fator de capacidade do dia", + "voltages": "Tensão em cada etapa", + "voltagesValue": "{{generator}} V, {{collector}} kV, {{grid}} kV", + "annual": "Uma turbina em um ano", + "annualHomes": "Casas nos EUA que uma turbina abastece", + "carbon": "Pegada de carbono", + "payback": "Retorno energético" + } + }, + "parts": { + "land": "Solo", + "foundation": "Fundação", + "tower": "Torre", + "transformer": "Transformador", + "towerCable": "Cabo de energia", + "nacelle": "Nacele", + "cooler": "Resfriador", + "bedplate": "Chassi", + "hub": "Cubo", + "blades": "Pás", + "pitchCylinders": "Cilindros de passo", + "mainBearing": "Rolamento principal", + "mainShaft": "Eixo principal", + "gearbox": "Caixa de engrenagens", + "brakeDisc": "Freio a disco", + "generator": "Gerador", + "converter": "Conversor", + "yawDrives": "Motores de guinada", + "sweptArea": "Área varrida", + "streamlinesGroup": "Fluxo de ar", + "heroWake": "Esteira", + "farmLand": "Lavouras", + "farmTurbines": "27 turbinas", + "accessRoads": "Estradas de acesso", + "spacingMarker": "Distância entre fileiras", + "prevailingWind": "Vento predominante", + "windArrows": "Vento", + "shearProfile": "Vento conforme a altura", + "wakePlumes": "Esteiras", + "collectorCables": "Cabos coletores", + "substation": "Subestação", + "gridLine": "Linha de transmissão" + }, + "sections": { + "farm": { + "title": "Vinte e sete turbinas e um dia de vento", + "p1": "Este é um parque eólico: 27 turbinas espalhadas por campos ondulados. Cada uma é uma Vestas V150 e, na potência máxima, gera 4,2 megawatts (MW). Um megawatt é um milhão de watts, então o parque inteiro pode gerar cerca de 113 MW. Ele tem o tamanho de Målarberget, na Suécia, um parque real com 27 dessas turbinas que funciona desde 2021.", + "p2": "As turbinas ficam em fileiras que cortam o caminho do vento predominante, a direção de onde o vento mais sopra. Aqui, ele vem do oeste. Os engenheiros medem as distâncias em diâmetros do rotor, escritos como D, e aqui um D vale 150 m. As fileiras ficam a 7 D umas das outras, ou 1.050 m, e as turbinas de uma mesma fileira ficam a 4 D, ou 600 m. Cada fileira é deslocada para o lado, para que nenhuma turbina fique bem atrás de outra da fileira da frente.", + "p3": "Visto de cima, o parque parece lotado, mas o terreno continua sendo, em grande parte, lavoura. Só cerca de 0,3 hectare por megawatt é de fato ocupado por construções, e a maior parte dessa área são estradas de acesso, não as bases das turbinas. Um hectare é um quadrado de 100 m de lado. A energia segue pelos cabos coletores até uma subestação, de onde parte para a rede elétrica.", + "p4": "A disposição real das turbinas em Målarberget não foi publicada, então a que você vê foi criada para este modelo. O modelo também repete sem parar um dia de vento inventado. Não é um dia médio: ele junta em 24 horas uma noite calma, uma manhã de brisa, uma tarde de potência máxima e uma tempestade no fim da tarde. O relógio corre centenas de vezes mais rápido que o tempo real, mas cada rotor gira na sua velocidade de verdade. Estradas, cabos e setas de vento aparecem mais largos do que são para que você os veja a quilômetros de distância, e a luz não acompanha o relógio." + }, + "tower": { + "title": "Pás que voam em círculos", + "p1": "De perto, uma turbina é enorme. A torre de aço ergue o cubo a 105 m do chão, e o rotor, o cubo com suas três pás, tem 150 m de diâmetro. No ponto mais alto da volta, a ponta de uma pá chega a cerca de 180 m, quase o dobro da altura do Big Ben. Lá em cima o vento sopra mais rápido do que perto do chão, como mostra o capítulo 5.", + "p2": "Cada pá tem cerca de 74 m de comprimento, quase a envergadura de um Airbus A380, o maior avião de passageiros. Ela é feita de fibra de carbono e fibra de vidro presas em epóxi, uma cola plástica dura. A pá não é uma vela que o vento empurra. O vento passa por ela como pela asa de um avião, e o formato curvo cria sustentação, uma força que age de lado em relação ao vento. A sustentação puxa cada pá ao longo do círculo, e o rotor gira.", + "p3": "Na velocidade máxima, o rotor dá só cerca de 10 voltas por minuto, ou 10 rpm (rotações por minuto). Isso dá uma volta a cada 6 segundos, o que parece até preguiçoso. Mas as pontas percorrem um círculo enorme, então andam a cerca de 80 m/s, perto de 300 km/h. Arraste o controle de vento para ver o rotor acelerar junto com o vento, ou escolha Seguir o dia para que o vento volte a seguir o relógio. Neste modelo, as pontas andam 8 vezes mais rápido que o vento até o rotor atingir sua velocidade máxima.", + "p4": "O círculo que as pás percorrem é a área varrida: cerca de 17.700 m², o tamanho de uns 2,5 campos de futebol. Só o vento que passa por esse círculo consegue girar o rotor. As linhas de ar em volta do rotor são um desenho, já que o ar de verdade é invisível. Neste modelo, o transformador fica no pé da torre, porque a posição dele na V150 não foi publicada. Pelo mesmo motivo, a leve inclinação do rotor não aparece no desenho." + }, + "nacelle": { + "title": "Dentro da nacele", + "p1": "A caixa comprida atrás do cubo é a nacele, com 12,8 m de comprimento. Escolha Sem cobertura para ver por dentro. Ela abriga as máquinas que transformam o giro lento e forte do rotor em eletricidade. Todas ficam sobre o chassi, uma estrutura pesada de ferro fundido, e um resfriador no teto leva o calor delas embora.", + "p2": "No cubo, cada pá fica sobre um rolamento, para poder girar em torno do próprio eixo. Três cilindros de passo, um por pá, fazem esse giro com pressão de óleo. O ângulo da pá em relação ao vento é o seu passo, e ele define quanto o vento consegue agarrar. Atrás do cubo, o eixo principal gira no rolamento principal na mesma velocidade lenta do rotor, cerca de 10 rpm.", + "p3": "Um gerador precisa girar rápido, então há uma caixa de engrenagens no meio do caminho. Ela tem dois estágios planetários, em que engrenagens pequenas giram em volta de uma central, e um estágio helicoidal, com dentes inclinados. Juntos, eles multiplicam a rotação do eixo por cerca de 140, chegando a uns 1.485 rpm. O gerador é do tipo de indução, com enrolamentos de cobre e sem ímãs. Toda a energia dele, a 800 volts, passa pelo conversor, que a ajusta para combinar com a rede.", + "p4": "No eixo fica um freio a disco. Não é ele que para a turbina numa tempestade: quem faz isso são as pás, que giram até ficar com a borda virada para o vento. O freio só segura o rotor quando ele já está lento, para deixá-lo travado. No alto da torre, motores de guinada giram a nacele inteira para que ela fique de frente para o vento. Neste modelo, ela acompanha o vento com exatidão, porque a velocidade real de giro da V150 não foi publicada." + }, + "curve": { + "title": "Quanto vento uma turbina consegue captar", + "p1": "Abaixo de 3 m/s, a velocidade de partida, o rotor espera. Acima dela, a potência sobe rápido, porque a potência do vento cresce com a velocidade ao cubo: velocidade vezes velocidade vezes velocidade. Com o dobro de vento, ele carrega oito vezes mais potência. Por isso a turbina gera cerca de 0,3 MW a 4 m/s, cerca de 2,5 MW a 8 m/s e sua potência máxima de 4,2 MW a partir de uns 12 m/s. Arraste o controle de vento e veja o ponto andar pela curva.", + "p2": "Nenhuma turbina consegue captar tudo. Para tirar energia, o rotor precisa frear o ar, mas o ar ainda tem que sair por trás dele. O físico Albert Betz mostrou que nem um rotor perfeito consegue tirar mais de 59,3 % da potência. Esse é o limite de Betz. No seu melhor momento, com ventos de cerca de 7 a 8 m/s, esta turbina capta pouco menos da metade, e o gráfico mostra o quanto ela chega perto do limite.", + "p3": "Depois da potência máxima, o vento traz mais energia do que o gerador aguenta. Então os cilindros de passo giram as pás um pouco para fora do vento, e a maior parte dele passa direto. A 15 m/s, a turbina fica com só cerca de 12 % da potência do vento. Os ângulos de passo mostrados nessa faixa foram criados para este modelo, já que os da V150 não foram publicados. Acima de 20 m/s, a potência começa a cair aos poucos.", + "p4": "Na velocidade de corte, cerca de 22 a 25 m/s, as pás giram até ficar com a borda virada para o vento. Isso é o embandeiramento: o rotor desacelera sozinho, e depois o freio a disco o trava. A turbina só volta a funcionar quando o vento cai cerca de 2 m/s. Neste modelo, as 27 turbinas param juntas a 24,5 m/s e voltam a girar a 22,5 m/s. A parada e a volta levam 20 minutos do relógio cada uma, mais do que na realidade, para que você possa acompanhar." + }, + "wakes": { + "title": "Por que as turbinas ficam tão afastadas", + "p1": "Uma turbina tira energia do vento, então o ar atrás dela anda mais devagar. Esse rastro de ar lento e agitado se chama esteira. Ative Esteiras (K) para ver uma pluma atrás de cada rotor em movimento. As esteiras são mais fortes com vento moderado, quando o rotor segura o ar com mais força. Depois da potência máxima, as pás deixam mais vento passar, e as esteiras vão sumindo.", + "p2": "Este modelo desenha as esteiras com uma receita simples e muito usada, o modelo de Jensen. Ele trata cada esteira como um cone que se alarga e aos poucos volta a se encher de ar mais rápido. A rapidez com que o cone se alarga depende de um único número, aqui 0,075, o valor comum para parques em terra. A 8 m/s, uma turbina 7 D mais adiante, no sentido do vento, ainda recebe ar cerca de 13 % mais lento. Como a potência segue o vento ao cubo, isso custa a ela cerca de um terço da potência.", + "p3": "Ao longo de um ano, as esteiras custam a um parque cerca de 5 a 15 % da energia. Um parque de exemplo da Vestas, com 24 dessas turbinas, perde cerca de 6 %. Para uma única direção de vento, a perda pode passar de 30 %. Por isso, as fileiras costumam ficar de 5 a 9 D umas das outras no sentido do vento, e as turbinas de cada fileira, de 3 a 5 D entre si. Experimente 5, 7 e 9 D: distâncias maiores perdem menos vento, mas precisam de mais terra.", + "p4": "O vento também aumenta com a altura, porque o chão freia o ar que está perto dele. Ative Fluxo de ar (F) para ver o perfil: o alto do rotor recebe vento mais rápido do que a parte de baixo. As plumas e as setas são desenhos de um ar que ninguém vê. Neste modelo, o terreno é plano, um rotor parado não deixa esteira, e as esteiras que se sobrepõem se somam de um jeito simples." + }, + "grid": { + "title": "O caminho até a rede", + "p1": "Cada gerador produz energia a 800 volts (V). Depois do conversor, o transformador da própria turbina eleva a tensão. Com tensão mais alta, a mesma potência passa com menos corrente, e assim menos energia se perde como calor nos cabos. Ative Cabos (C) para seguir a energia das turbinas até a rede.", + "p2": "Os cabos coletores, a 36.000 volts, ou 36 quilovolts (kV), ligam as turbinas à subestação. Lá, outro transformador eleva a tensão para 110 kV, a da linha de transmissão. Essas tensões vêm de um parque de exemplo da Vestas na Alemanha.", + "p3": "Na potência máxima, uma turbina gera 4,2 megawatts-hora (MWh) a cada hora. Mas o vento nem sempre é forte, então ao longo de um ano uma turbina rende em média cerca de 40 % da sua potência máxima. Essa parcela é o fator de capacidade. Em um ano, uma turbina gera cerca de 14.700 MWh, o bastante para umas 1.400 casas nos EUA. O dia inventado do modelo tem mais vento do que a média de um ano, por isso o fator de capacidade dele sai mais alto.", + "p4": "Fabricar uma turbina também custa energia e carbono. Ao longo da vida útil, ela soma cerca de 7 g de CO2e para cada quilowatt-hora (kWh) que gera, e o CO2e conta todo gás de efeito estufa como se fosse dióxido de carbono. Em cerca de 8 meses, ela gera de volta a energia usada para construí-la. Foi projetada para durar 20 anos e muitas vezes dura mais, embora as pás ainda sejam difíceis de reciclar. Escolha um local calmo, típico ou ventoso para ver o quanto o vento do dia a dia faz diferença." + } + } +} From c858d1f2df4bedb2a44b41fdac497727ff5d0b3a Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:04:15 +0200 Subject: [PATCH 024/132] feat(wind-farm): add the Chinese locale --- explainers/wind-farm/locales/zh.json | 269 +++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 explainers/wind-farm/locales/zh.json diff --git a/explainers/wind-farm/locales/zh.json b/explainers/wind-farm/locales/zh.json new file mode 100644 index 00000000..29fda2ee --- /dev/null +++ b/explainers/wind-farm/locales/zh.json @@ -0,0 +1,269 @@ +{ + "meta": { + "title": "风电场是怎样工作的", + "eyebrow": "可再生能源", + "tagline": "二十七台风机,一个有风的日子:像机翼一样的叶片,怎样把流动的空气变成电,送进电网。", + "description": "跟着3D风电场度过有风的一天:叶片怎样抓住风,机舱里什么在转,风机为什么隔得很远,电又怎样送进电网。", + "summary": "风电场布局、塔筒与叶片、齿轮箱与发电机、功率曲线与贝茨极限、尾流,还有通往电网的路。", + "socialAlt": "起伏的田野上有一座风电场,近处是一台风机的特写,旁边是标题“风电场是怎样工作的”。" + }, + "stage": { + "label": "风电场互动模型", + "gauge": "实时风速与功率" + }, + "timeline": { + "label": "一天中的时间", + "phases": "跳到一天中的某个时段", + "speed": "播放速度", + "value": "{{time}}", + "during": "{{time}},{{phase}}", + "speedValue": "{{value}}小时/分钟", + "speedFormat": "每分钟播放一天中的{{value}}小时", + "phase": { + "night": "无风的夜晚", + "morning": "早晨的微风", + "afternoon": "满功率", + "storm": "风暴", + "evening": "傍晚" + }, + "jump": { + "night": "跳到无风的夜晚", + "morning": "跳到早晨的微风", + "afternoon": "跳到满功率", + "storm": "跳到风暴", + "evening": "跳到傍晚" + } + }, + "controls": { + "label": "模型控制", + "siteWind": "场址风况", + "siteWindOptions": { + "calm": "风小的场址", + "typical": "普通场址", + "windy": "多风的场址" + }, + "spacingOptions": { + "5": "5 D", + "7": "7 D", + "9": "9 D" + }, + "nacelleOptions": { + "closed": "合上外罩", + "open": "打开外罩" + }, + "windAtOptions": { + "day": "跟随这一天", + "cutIn": "切入风速,3米/秒", + "peak": "效率最高,8米/秒", + "rated": "满功率,12米/秒", + "rampDown": "功率下调,23米/秒", + "cutOut": "已过切出风速,25米/秒" + }, + "groups": { + "siteWind": "场址风况", + "spacing": "排间距", + "nacelle": "机舱外罩", + "windAt": "风速" + }, + "views": { + "streamlines": { + "name": "气流", + "short": "气流", + "hint": "气流(F)" + }, + "wakes": { + "name": "尾流", + "short": "尾流", + "hint": "尾流(K)" + }, + "cables": { + "name": "电缆", + "short": "电缆", + "hint": "电缆(C)" + }, + "labels": { + "name": "标签", + "short": "标签", + "hint": "标签(L)" + } + } + }, + "readouts": { + "wind": "风速", + "power": "单台风机", + "farm": "整座风电场", + "state": { + "idle": "等风中", + "partial": "部分负荷", + "full": "满功率", + "rampDown": "功率下调", + "stopping": "顺桨停机中", + "parked": "已停机", + "starting": "正在启动" + } + }, + "units": { + "metresPerSecond": "{{value}}米/秒", + "speedPair": "{{ms}}米/秒,{{kmh}}公里/小时", + "rpm": "{{value}}转/分", + "degrees": "{{value}}°", + "mw": "{{value}}兆瓦", + "mwh": "{{value}}兆瓦时", + "percent": "{{value}}%", + "metres": "{{value}}米", + "seconds": "{{value}}秒", + "count": "{{value}}", + "hectares": "{{value}}公顷", + "squareKm": "{{value}}平方公里", + "volts": "{{value}}伏", + "gramsPerKwh": "每千瓦时{{value}}克CO2e", + "months": "{{value}}个月" + }, + "chapters": { + "farm": { + "count": "风机数量", + "rated": "风电场满功率", + "rows": "排与排之间,顺着风向", + "across": "同一排风机之间", + "spacingValue": "{{metres}}米,{{diameters}} D", + "built": "实际占用的土地", + "project": "项目总面积" + }, + "tower": { + "wind": "轮毂高度的风速", + "rpm": "转子转速", + "turn": "转一圈用时", + "stopped": "转子已停", + "tip": "叶尖速度", + "height": "叶尖最高处", + "blade": "叶片长度", + "swept": "扫掠面积", + "sweptValue": "{{area}}平方米,约{{pitches}}个足球场" + }, + "nacelle": { + "shaft": "主轴", + "generator": "发电机", + "gear": "增速比", + "gearValue": "约1比{{ratio}}", + "pitch": "叶片桨距角", + "brake": "盘式制动器", + "brakeValue": { + "released": "已松开", + "holding": "正在锁住转子" + }, + "volts": "发电机电压" + }, + "curve": { + "wind": "风速", + "state": "风机状态", + "power": "风机输出", + "caption": "风中的功率、贝茨极限和这台风机的输出,风速从0到30米/秒", + "axisWind": "风速(米/秒)", + "axisPower": "功率(兆瓦)", + "lineWind": "风中的功率", + "lineBetz": "贝茨极限", + "lineTurbine": "这台风机", + "windPower": "风中的功率", + "cp": "抓住的比例", + "betz": "相当于贝茨极限的比例", + "pitch": "叶片桨距角" + }, + "wakes": { + "spacing": "排间距", + "deficit": "尾流让下一排的风变慢", + "loss": "此刻尾流造成的全场损失", + "top": "转子顶部的风速", + "bottom": "转子底部的风速" + }, + "grid": { + "output": "风电场此刻的输出", + "energy": "今天的发电量", + "homes": "此刻的电够多少户美国家庭用", + "capacity": "这一天的容量系数", + "voltages": "每一级的电压", + "voltagesValue": "{{generator}}伏,{{collector}}千伏,{{grid}}千伏", + "annual": "单台风机一年的发电量", + "annualHomes": "单台风机够多少户美国家庭用", + "carbon": "碳足迹", + "payback": "能量回收期" + } + }, + "parts": { + "land": "地面", + "foundation": "基础", + "tower": "塔筒", + "transformer": "变压器", + "towerCable": "电力电缆", + "nacelle": "机舱", + "cooler": "冷却器", + "bedplate": "底座", + "hub": "轮毂", + "blades": "叶片", + "pitchCylinders": "变桨油缸", + "mainBearing": "主轴承", + "mainShaft": "主轴", + "gearbox": "齿轮箱", + "brakeDisc": "盘式制动器", + "generator": "发电机", + "converter": "变流器", + "yawDrives": "偏航驱动", + "sweptArea": "扫掠面积", + "streamlinesGroup": "气流", + "heroWake": "尾流", + "farmLand": "农田", + "farmTurbines": "27台风机", + "accessRoads": "检修道路", + "spacingMarker": "排间距", + "prevailingWind": "主导风向", + "windArrows": "风", + "shearProfile": "不同高度的风速", + "wakePlumes": "尾流", + "collectorCables": "集电电缆", + "substation": "变电站", + "gridLine": "输电线路" + }, + "sections": { + "farm": { + "title": "二十七台风机,一个有风的日子", + "p1": "这是一座风电场:27台风机散布在起伏的田野上。每台都是维斯塔斯V150,满功率时能发出4.2兆瓦(MW)的电。1兆瓦就是100万瓦,所以整座风电场加起来能发约113兆瓦。它的规模和瑞典的Målarberget风电场差不多,那是一座真实的风电场,同样有27台这种风机,2021年起投入运行。", + "p2": "风机分成几排,每一排都横对着主导风向,也就是风最常吹来的方向。这里的主导风向是西风。工程师用转子直径来衡量间距,记作D,这里1 D是150米。排与排相隔7 D,也就是1050米;同一排的风机相隔4 D,也就是600米。每一排都往旁边错开一些,所以没有哪台风机正好站在前一排某台风机的正后方。", + "p3": "从空中看,风机好像很密,其实大部分土地仍是农田。每兆瓦真正占用的土地只有约0.3公顷,而且大多是检修道路,不是风机底座。1公顷就是边长100米的正方形。电沿着集电电缆汇到变电站,再从那里送往电网。", + "p4": "Målarberget的真实布局没有公开,所以你看到的是这个模型自己的布局。模型还会循环播放虚构的一天里的风。这并不是寻常的一天:它把无风的夜晚、有微风的早晨、满功率的下午和傍晚的风暴都塞进了24小时。时钟比真实时间快几百倍,但每个转子都按真实的速度转。为了让你在几公里外也能看清,道路、电缆和风向箭头都画得比实际宽,光照也不跟着时钟变化。" + }, + "tower": { + "title": "绕着圈飞的叶片", + "p1": "走近一看,一台风机大得惊人。钢制塔筒把轮毂举到离地105米的高处。转子就是轮毂加上三片叶片,直径有150米。叶片转到最高点时,叶尖离地约180米,差不多是大本钟的两倍高。那么高的地方,风比地面附近更快,第5章会讲到。", + "p2": "每片叶片长约74米,差不多和最大的客机空客A380的翼展一样长。它由碳纤维和玻璃纤维嵌在环氧树脂里做成,环氧树脂是一种坚硬的塑料胶。叶片不是靠风推着走的帆。风像流过飞机机翼那样流过叶片,弯曲的外形会产生升力,这是一种和风向垂直的力。升力拉着每片叶片往前跑,转子就转起来了。", + "p3": "转子全速时每分钟只转约10圈,记作10转/分。也就是6秒转一圈,看起来慢悠悠的。可叶尖绕的圆非常大,所以速度能达到约80米/秒,接近每小时300公里。拖动风速滑块,看转子怎样随风加快;选“跟随这一天”,风速就重新交给时钟。在这个模型里,转子达到最高转速之前,叶尖速度一直是风速的8倍。", + "p4": "叶片扫过的圆叫扫掠面积:约1.77万平方米,大约有2.5个足球场那么大。只有穿过这个圆的风,才能让转子转动。转子周围的气流线只是示意,真实的空气是看不见的。在这个模型里,变压器放在塔筒底部,因为V150实际把它装在哪里没有公开。出于同样的原因,转子那一点轻微的倾角也没有画出来。" + }, + "nacelle": { + "title": "机舱里有什么", + "p1": "轮毂后面那个长长的箱子是机舱,长12.8米。选“打开外罩”就能看到里面。里面的机器把转子又慢又有力的转动变成电。它们都装在底座上,那是一个沉重的铸铁框架,机舱顶上的冷却器则负责把它们的热量带走。", + "p2": "在轮毂里,每片叶片都装在一个轴承上,所以能绕自身转动。三个变桨油缸各管一片叶片,靠油压来扭转叶片。叶片相对于风的角度叫桨距角,它决定叶片能吃到多少风。轮毂后面,主轴在主轴承里转动,转速和转子一样慢,约10转/分。", + "p3": "发电机需要转得很快,所以中间要加一个齿轮箱。它有两级行星齿轮,小齿轮围着中间的大齿轮转,还有一级斜齿轮,齿是斜着的。几级合在一起,让转速变成原来的约140倍,最高约1485转/分。发电机是一台感应发电机,用铜绕组,没有磁铁。它以800伏发出的电全部要经过变流器,由变流器调整成适合电网的样子。", + "p4": "主轴上装着盘式制动器。风暴来时让风机停下的并不是它,而是叶片:叶片会侧过来,让窄窄的边缘对着风。制动器只在转子慢下来以后把它锁住,让它停稳。塔筒顶上的偏航驱动会转动整个机舱,让它对着来风。在这个模型里,机舱完全跟着风转,因为V150实际转得多快没有公开。" + }, + "curve": { + "title": "一台风机能抓住多少风", + "p1": "风速不到3米/秒时,转子只是等着,这个风速叫切入风速。一过这个风速,功率就快速上升,因为风中的功率和风速的三次方成正比,也就是速度乘速度再乘速度。风速翻一倍,风带来的功率就变成八倍。所以这台风机在4米/秒时约发0.3兆瓦,8米/秒时约2.5兆瓦,从约12米/秒起达到满功率4.2兆瓦。拖动风速滑块,看那个点沿着曲线移动。", + "p2": "没有哪台风机能把风的功率全部抓住。要取走能量,转子就得让空气慢下来,可空气还得能从它后面流走。物理学家阿尔伯特·贝茨证明,即使是完美的转子,最多也只能取走风中59.3%的功率。这就是贝茨极限。这台风机在约7到8米/秒时表现最好,能抓住略少于一半的功率,图上能看出它离这个极限有多近。", + "p3": "风速一旦超过满功率点,风带来的功率就多于发电机能接收的。于是变桨油缸把叶片稍稍转离风向,让大部分风从旁边溜走。在15米/秒时,风机只留下风中约12%的功率。满功率以上显示的桨距角是这个模型自己设定的,因为V150的实际数值没有公开。风速超过20米/秒后,功率开始下调。", + "p4": "到了约22到25米/秒的切出风速,叶片会侧过来,让边缘对着风。这叫顺桨:转子自己慢下来,然后盘式制动器让它停住。风速要回落约2米/秒,风机才会重新启动。在这个模型里,27台风机在24.5米/秒时一起停机,在22.5米/秒时重新启动。停机和重启各占时钟上的20分钟,比实际更长,好让你看清整个过程。" + }, + "wakes": { + "title": "风机为什么隔得这么远", + "p1": "风机从风里取走能量,所以它身后的空气会变慢。这股又慢又乱的气流叫尾流。打开“尾流(K)”,就能看到每个转动的转子后面都拖着一道尾迹。风不大不小时尾流最强,因为这时转子对空气的阻挡最厉害。超过满功率后,叶片放更多的风溜过去,尾流也就变弱了。", + "p2": "这个模型用一种简单又常用的方法来画尾流,叫詹森模型。它把每道尾流看成一个圆锥,越往后越宽,并慢慢被较快的空气重新填满。圆锥张开的快慢由一个数字决定,这里取0.075,是陆上的常用值。风速8米/秒时,下风向7 D处的风机迎来的风仍要慢约13%。由于功率随风速的三次方变化,这会让它少发约三分之一的电。", + "p3": "按一整年算,尾流会让一座风电场少发约5%到15%的电。维斯塔斯举过一个例子,一座有24台这种风机的风电场,损失约6%。如果只看某一个风向,损失可能超过30%。所以风机通常沿风向相隔5到9 D,横向相隔3到5 D。试试5 D、7 D和9 D:间距越大,损失的风越少,但要占的地也越多。", + "p4": "风速还会随高度增加,因为地面会拖住贴近它的空气。打开“气流(F)”就能看到不同高度的风速:转子顶部迎来的风比底部更快。尾迹和箭头都只是对看不见的空气的示意。在这个模型里,地面是平的,停转的转子不留尾流,相互重叠的尾流也只是用简单的方法叠加。" + }, + "grid": { + "title": "通往电网的路", + "p1": "每台发电机发出的电是800伏(V)。电经过变流器后,风机自带的变压器会把电压升高。电压越高,输送同样的功率需要的电流就越小,电缆里白白变成热量的电也就越少。打开“电缆(C)”,就能跟着电从风机一路走到电网。", + "p2": "集电电缆的电压是3.6万伏,也就是36千伏(kV),它把各台风机连到变电站。在那里,另一台变压器再把电压升到110千伏,送上输电线路。这些电压数值来自维斯塔斯在德国的一个示例风电场。", + "p3": "满功率时,一台风机每小时发电4.2兆瓦时(MWh)。但风不会一直那么大,所以一年下来,一台风机的平均出力约为满功率的40%。这个比例叫容量系数。一台风机一年约发电1.47万兆瓦时,够约1400户美国家庭使用。模型里虚构的那一天比全年平均风大,所以算出来的容量系数更高。", + "p4": "造风机本身也要消耗能量、排放碳。在整个寿命里,它每发1千瓦时(kWh)电,约排放7克二氧化碳当量(CO2e),也就是把各种温室气体都折算成二氧化碳来算。它用约8个月就能把建造时消耗的能量赚回来。风机按20年设计,实际常常用得更久,不过叶片至今仍很难回收。在“场址风况”里选风小、普通或多风的场址,看看日常的风有多重要。" + } + } +} From 7616211994deb16bf248a13a02c16cb433682355 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:05:29 +0200 Subject: [PATCH 025/132] feat(wind-farm): add the Ukrainian locale --- explainers/wind-farm/locales/uk.json | 269 +++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 explainers/wind-farm/locales/uk.json diff --git a/explainers/wind-farm/locales/uk.json b/explainers/wind-farm/locales/uk.json new file mode 100644 index 00000000..20e0a442 --- /dev/null +++ b/explainers/wind-farm/locales/uk.json @@ -0,0 +1,269 @@ +{ + "meta": { + "title": "Як працює вітрова електростанція", + "eyebrow": "Відновлювана енергетика", + "tagline": "Двадцять сім турбін і один вітряний день: як лопать у формі крила перетворює рух повітря на електрику для мережі.", + "description": "Вітрова електростанція у 3D протягом вітряного дня: як лопаті ловлять вітер, що крутиться в гондолі, чому турбіни стоять далеко і як струм іде в мережу.", + "summary": "Розташування станції, башта й лопаті, редуктор і генератор, крива потужності й межа Беца, сліди за турбінами та шлях до мережі.", + "socialAlt": "Вітрова електростанція серед пологих пагорбів, одна турбіна великим планом, поруч заголовок «Як працює вітрова електростанція»" + }, + "stage": { + "label": "Інтерактивна модель вітрової електростанції", + "gauge": "Вітер і потужність зараз" + }, + "timeline": { + "label": "Час доби", + "phases": "Перейти до частини доби", + "speed": "Швидкість відтворення", + "value": "{{time}}", + "during": "{{time}}, {{phase}}", + "speedValue": "{{value}} год/хв", + "speedFormat": "Годин доби за хвилину: {{value}}", + "phase": { + "night": "Тиха ніч", + "morning": "Ранковий вітерець", + "afternoon": "Повна потужність", + "storm": "Буря", + "evening": "Вечір" + }, + "jump": { + "night": "Перейти до тихої ночі", + "morning": "Перейти до ранкового вітерцю", + "afternoon": "Перейти до повної потужності", + "storm": "Перейти до бурі", + "evening": "Перейти до вечора" + } + }, + "controls": { + "label": "Керування моделлю", + "siteWind": "Вітер у місцевості", + "siteWindOptions": { + "calm": "Тиха місцевість", + "typical": "Звичайна місцевість", + "windy": "Вітряна місцевість" + }, + "spacingOptions": { + "5": "5 D", + "7": "7 D", + "9": "9 D" + }, + "nacelleOptions": { + "closed": "З кожухом", + "open": "Без кожуха" + }, + "windAtOptions": { + "day": "За ходом дня", + "cutIn": "Увімкнення, 3 м/с", + "peak": "Найкраща частка, 8 м/с", + "rated": "Повна потужність, 12 м/с", + "rampDown": "Спад потужності, 23 м/с", + "cutOut": "За межею вимкнення, 25 м/с" + }, + "groups": { + "siteWind": "Вітер у місцевості", + "spacing": "Відстань між рядами", + "nacelle": "Кожух гондоли", + "windAt": "Швидкість вітру" + }, + "views": { + "streamlines": { + "name": "Потік повітря", + "short": "Повітря", + "hint": "Потік повітря (F)" + }, + "wakes": { + "name": "Сліди", + "short": "Сліди", + "hint": "Сліди (K)" + }, + "cables": { + "name": "Кабелі", + "short": "Кабелі", + "hint": "Кабелі (C)" + }, + "labels": { + "name": "Підписи", + "short": "Підписи", + "hint": "Підписи (L)" + } + } + }, + "readouts": { + "wind": "Вітер", + "power": "Одна турбіна", + "farm": "Уся станція", + "state": { + "idle": "Чекає на вітер", + "partial": "Часткове навантаження", + "full": "Повна потужність", + "rampDown": "Спад потужності", + "stopping": "Флюгування до зупинки", + "parked": "Загальмована", + "starting": "Запускається" + } + }, + "units": { + "metresPerSecond": "{{value}} м/с", + "speedPair": "{{ms}} м/с, {{kmh}} км/год", + "rpm": "{{value}} об/хв", + "degrees": "{{value}}°", + "mw": "{{value}} МВт", + "mwh": "{{value}} МВт·год", + "percent": "{{value}} %", + "metres": "{{value}} м", + "seconds": "{{value}} с", + "count": "{{value}}", + "hectares": "{{value}} га", + "squareKm": "{{value}} км²", + "volts": "{{value}} В", + "gramsPerKwh": "{{value}} г CO2-екв. на кВт·год", + "months": "{{value}} місяця" + }, + "chapters": { + "farm": { + "count": "Турбіни", + "rated": "Повна потужність станції", + "rows": "Між рядами, уздовж вітру", + "across": "Між турбінами в ряду", + "spacingValue": "{{metres}} м, {{diameters}} D", + "built": "Забудовано землі", + "project": "Уся площа проєкту" + }, + "tower": { + "wind": "Вітер на висоті маточини", + "rpm": "Швидкість ротора", + "turn": "Один оберт триває", + "stopped": "Ротор зупинено", + "tip": "Швидкість кінця лопаті", + "height": "Найвища точка лопаті", + "blade": "Довжина лопаті", + "swept": "Ометана площа", + "sweptValue": "{{area}} м², приблизно {{pitches}} футбольного поля" + }, + "nacelle": { + "shaft": "Головний вал", + "generator": "Генератор", + "gear": "Передавальне число", + "gearValue": "приблизно 1 : {{ratio}}", + "pitch": "Кут повороту лопатей", + "brake": "Дискове гальмо", + "brakeValue": { + "released": "Відпущене", + "holding": "Тримає ротор" + }, + "volts": "Напруга генератора" + }, + "curve": { + "wind": "Швидкість вітру", + "state": "Стан турбіни", + "power": "Потужність турбіни", + "caption": "Потужність вітру, межа Беца й потужність цієї турбіни за вітру від 0 до 30 м/с", + "axisWind": "Швидкість вітру (м/с)", + "axisPower": "Потужність (МВт)", + "lineWind": "Потужність вітру", + "lineBetz": "Межа Беца", + "lineTurbine": "Ця турбіна", + "windPower": "Потужність вітру", + "cp": "Частка, яку вона вловлює", + "betz": "Частка від межі Беца", + "pitch": "Кут повороту лопатей" + }, + "wakes": { + "spacing": "Відстань між рядами", + "deficit": "Сповільнення вітру в наступному ряду", + "loss": "Втрати станції через сліди зараз", + "top": "Вітер угорі ротора", + "bottom": "Вітер унизу ротора" + }, + "grid": { + "output": "Потужність станції зараз", + "energy": "Вироблено енергії сьогодні", + "homes": "Скільки домівок у США це могло б живити", + "capacity": "Коефіцієнт використання потужності за день", + "voltages": "Напруга на кожному етапі", + "voltagesValue": "{{generator}} В, {{collector}} кВ, {{grid}} кВ", + "annual": "Одна турбіна за рік", + "annualHomes": "Скільки домівок у США живить одна турбіна", + "carbon": "Вуглецевий слід", + "payback": "Енергетична окупність" + } + }, + "parts": { + "land": "Ґрунт", + "foundation": "Фундамент", + "tower": "Башта", + "transformer": "Трансформатор", + "towerCable": "Силовий кабель", + "nacelle": "Гондола", + "cooler": "Охолоджувач", + "bedplate": "Опорна рама", + "hub": "Маточина", + "blades": "Лопаті", + "pitchCylinders": "Циліндри повороту лопатей", + "mainBearing": "Головний підшипник", + "mainShaft": "Головний вал", + "gearbox": "Редуктор", + "brakeDisc": "Дискове гальмо", + "generator": "Генератор", + "converter": "Перетворювач", + "yawDrives": "Приводи повороту гондоли", + "sweptArea": "Ометана площа", + "streamlinesGroup": "Потік повітря", + "heroWake": "Слід", + "farmLand": "Поля", + "farmTurbines": "27 турбін", + "accessRoads": "Під’їзні дороги", + "spacingMarker": "Відстань між рядами", + "prevailingWind": "Переважний вітер", + "windArrows": "Вітер", + "shearProfile": "Вітер на різній висоті", + "wakePlumes": "Сліди", + "collectorCables": "Збірні кабелі", + "substation": "Підстанція", + "gridLine": "Лінія електропередачі" + }, + "sections": { + "farm": { + "title": "Двадцять сім турбін і один вітряний день", + "p1": "Перед вами вітрова електростанція: 27 турбін серед пологих полів. Це турбіни Vestas V150, і на повній потужності кожна дає 4,2 мегавата (МВт). Мегават дорівнює мільйону ватів, тож разом станція може дати близько 113 МВт. За розміром вона як Моларбер’єт у Швеції, справжня станція з 27 таких самих турбін, що працює з 2021 року.", + "p2": "Турбіни стоять рядами впоперек переважного вітру, тобто напрямку, звідки вітер дме найчастіше. Тут це захід. Інженери міряють відстані в діаметрах ротора, які позначають літерою D, і тут один D дорівнює 150 м. Ряди стоять на відстані 7 D, тобто 1050 м, а турбіни в ряду на відстані 4 D, тобто 600 м. Кожен ряд зсунутий убік, тож жодна турбіна не стоїть просто за турбіною з переднього ряду.", + "p3": "Згори здається, що тут тісно, але більша частина землі так і лишається полями. Забудовано лише близько 0,3 гектара на мегават, і здебільшого це під’їзні дороги, а не основи турбін. Гектар дорівнює квадрату зі стороною 100 м. Електрика тече збірними кабелями до підстанції, а звідти йде в мережу.", + "p4": "Справжня схема турбін у Моларбер’єті не оприлюднена, тож тут ви бачите власне розташування моделі. Ще модель програє по колу вигаданий вітряний день. Це не середній день: у 24 години вміщено тиху ніч, вітряний ранок, день на повній потужності й вечірню бурю. Годинник іде в сотні разів швидше, ніж насправді, але кожен ротор обертається зі своєю справжньою швидкістю. Дороги, кабелі та стрілки вітру намальовані ширшими, ніж є, щоб їх було видно за кілометри, а освітлення не залежить від годинника." + }, + "tower": { + "title": "Лопаті, що літають по колу", + "p1": "Зблизька видно, яка величезна навіть одна турбіна. Сталева башта піднімає маточину на 105 м над землею, а ротор, тобто маточина з трьома лопатями, має 150 м у діаметрі. У найвищій точці оберту кінець лопаті сягає приблизно 180 м, це майже вдвічі вище за Біг-Бен. Там угорі вітер дме швидше, ніж біля землі, як показує розділ 5.", + "p2": "Кожна лопать завдовжки близько 74 м, це майже розмах крил Airbus A380, найбільшого пасажирського літака. Її роблять із вуглеволокна й скловолокна, скріплених епоксидною смолою, тобто твердим пластиковим клеєм. Лопать працює не як вітрило, яке штовхає вітер. Вітер обтікає її, як крило літака, і завдяки вигнутій формі виникає підйомна сила, що діє впоперек вітру. Ця сила тягне кожну лопать по колу, і ротор обертається.", + "p3": "На повній швидкості ротор робить лише близько 10 обертів за хвилину, скорочено 10 об/хв. Це один оберт за 6 секунд, тож ротор здається неквапливим. Але кінці лопатей описують величезне коло, тому мчать зі швидкістю близько 80 м/с, майже 300 км/год. Потягніть повзунок вітру, щоб побачити, як ротор розганяється разом із вітром, або виберіть «За ходом дня», щоб вітер знову йшов за годинником. У цій моделі кінці лопатей рухаються у 8 разів швидше за вітер, доки ротор не досягне найвищої швидкості.", + "p4": "Коло, яке описують лопаті, називають ометаною площею: це близько 17 700 м², приблизно 2,5 футбольного поля. Крутити ротор може лише вітер, що проходить крізь це коло. Лінії повітря довкола ротора лише намальовані, бо справжнє повітря невидиме. Трансформатор у цій моделі стоїть біля підніжжя башти, бо де він розташований у V150, не оприлюднено. З тієї ж причини не показано й невеликий нахил ротора." + }, + "nacelle": { + "title": "Усередині гондоли", + "p1": "Довга коробка за маточиною називається гондолою, вона завдовжки 12,8 м. Виберіть «Без кожуха», щоб зазирнути всередину. Там розміщені механізми, які перетворюють повільне й потужне обертання ротора на електрику. Усі вони тримаються на опорній рамі, важкій чавунній конструкції, а охолоджувач на даху відводить від них тепло.", + "p2": "У маточині кожна лопать закріплена на підшипнику, тож може провертатися. Три циліндри повороту лопатей, по одному на кожну, провертають їх тиском оливи. Кут, під яким лопать стоїть до вітру, називають кутом повороту, і від нього залежить, скільки вітру вона може вхопити. За маточиною головний вал обертається в головному підшипнику так само повільно, як ротор, близько 10 об/хв.", + "p3": "Генератор має обертатися швидко, тому між валом і генератором стоїть редуктор. У ньому два планетарні ступені, де малі шестерні кружляють довкола центральної, і один косозубий ступінь із похилими зубцями. Разом вони пришвидшують обертання приблизно в 140 разів, і вал на виході розганяється десь до 1485 об/хв. Генератор тут асинхронний, з мідними обмотками й без жодних магнітів. Уся його електрика напругою 800 вольт іде через перетворювач, а той підлаштовує її під мережу.", + "p4": "На валу стоїть дискове гальмо. Але в бурю турбіну зупиняє не воно, а лопаті: вони повертаються до вітру ребром. Гальмо береться до роботи лише тоді, коли ротор уже сповільнився, і тримає його нерухомим. Угорі на башті приводи повороту гондоли розвертають усю гондолу назустріч вітру. У цій моделі вона повертається точно за вітром, бо справжня швидкість повороту V150 не оприлюднена." + }, + "curve": { + "title": "Скільки вітру може вловити турбіна", + "p1": "Поки вітер слабший за 3 м/с, тобто за швидкість увімкнення, ротор чекає. Далі потужність швидко росте, бо потужність вітру залежить від куба швидкості: швидкість, помножена на швидкість і ще раз на швидкість. Удвічі сильніший вітер несе у вісім разів більше потужності. Тож за вітру 4 м/с турбіна дає близько 0,3 МВт, за 8 м/с близько 2,5 МВт, а всі свої 4,2 МВт приблизно від 12 м/с. Потягніть повзунок вітру й подивіться, як точка рухається кривою.", + "p2": "Увесь вітер не вловить жодна турбіна. Щоб забрати енергію, ротор мусить сповільнити повітря, але воно все одно має йти далі, за ротор. Фізик Альберт Бец показав, що навіть ідеальний ротор може забрати щонайбільше 59,3 % потужності. Це і є межа Беца. Ця турбіна в найкращому разі вловлює трохи менше половини, за вітру приблизно від 7 до 8 м/с, і графік показує, наскільки близько вона підходить до межі.", + "p3": "Коли турбіна вже на повній потужності, вітер приносить більше, ніж може прийняти генератор. Тому циліндри повороту трохи відвертають лопаті від вітру, і більша частина його проходить повз. За 15 м/с турбіна бере лише близько 12 % потужності вітру. Кути повороту лопатей для такого сильного вітру в цій моделі придумані, бо справжніх кутів V150 не оприлюднено. Коли вітер перевищує 20 м/с, потужність починає спадати.", + "p4": "За швидкості вимкнення, приблизно від 22 до 25 м/с, лопаті повертаються до вітру ребром. Це флюгування: ротор сповільнюється сам, а потім його тримає дискове гальмо. Знову турбіна запускається лише тоді, коли вітер ослабне приблизно на 2 м/с. У цій моделі всі 27 турбін зупиняються разом за 24,5 м/с і знову запускаються за 22,5 м/с. Зупинка й запуск тривають по 20 хвилин за годинником моделі, довше, ніж насправді, щоб ви встигли їх роздивитися." + }, + "wakes": { + "title": "Чому турбіни стоять так далеко одна від одної", + "p1": "Турбіна забирає з вітру енергію, тож повітря позаду неї рухається повільніше. Цей шлейф повільного, збуреного повітря називають слідом. Увімкніть «Сліди (K)», і за кожним ротором, що обертається, з’явиться шлейф. Сліди найсильніші за помірного вітру, коли ротор найбільше стримує повітря. Коли вітер ще сильніший, лопаті пропускають більше повітря повз себе, і сліди слабнуть.", + "p2": "Ця модель малює сліди за простим і поширеним рецептом, який називають моделлю Єнсена. Кожен слід вона уявляє як конус, що розширюється й поволі знову наповнюється швидшим повітрям. Як швидко він розширюється, задає одне число, тут 0,075, звичне значення для суходолу. За вітру 8 м/с турбіна, що стоїть на 7 D далі за вітром, усе ще отримує повітря, повільніше приблизно на 13 %. Потужність залежить від куба швидкості вітру, тож через це турбіна втрачає приблизно третину потужності.", + "p3": "За рік сліди забирають у станції приблизно від 5 до 15 % енергії. У прикладі від Vestas станція з 24 таких турбін втрачає близько 6 %. Для одного напрямку вітру втрати можуть перевищити 30 %. Тому ряди зазвичай ставлять на відстані від 5 до 9 D уздовж вітру й від 3 до 5 D упоперек. Спробуйте 5, 7 і 9 D: ширші проміжки втрачають менше вітру, але потребують більше землі.", + "p4": "А ще вітер посилюється з висотою, бо земля гальмує повітря біля себе. Увімкніть «Потік повітря (F)», щоб побачити цей профіль: угорі ротора вітер швидший, ніж унизу. Шлейфи й стрілки лише зображують невидиме повітря. У цій моделі земля рівна, зупинений ротор не залишає сліду, а сліди, що перекриваються, додаються за простим правилом." + }, + "grid": { + "title": "Шлях до мережі", + "p1": "Кожен генератор виробляє струм напругою 800 вольт (В). Після перетворювача власний трансформатор турбіни підвищує напругу. За вищої напруги та сама потужність іде меншим струмом, тож менше її втрачається на нагрівання кабелів. Увімкніть «Кабелі (C)», щоб простежити шлях електрики від турбін до мережі.", + "p2": "Збірні кабелі напругою 36 000 вольт, або 36 кіловольт (кВ), з’єднують турбіни з підстанцією. Там інший трансформатор підвищує напругу до 110 кВ для лінії електропередачі. Ці напруги взято з прикладу Vestas для станції в Німеччині.", + "p3": "На повній потужності одна турбіна щогодини виробляє 4,2 мегават-години (МВт·год). Але вітер не завжди сильний, тож за рік турбіна в середньому дає близько 40 % своєї повної потужності. Цю частку називають коефіцієнтом використання потужності. За рік одна турбіна виробляє близько 14 700 МВт·год, і цього вистачає приблизно на 1400 домівок у США. Вигаданий день моделі вітряніший за середній день року, тому коефіцієнт тут виходить вищим.", + "p4": "Виготовлення турбіни теж забирає енергію й дає викиди вуглецю. За весь строк служби на кожну вироблену кіловат-годину (кВт·год) припадає близько 7 г CO2-екв., тобто всіх парникових газів, перерахованих так, ніби це вуглекислий газ. Енергію, витрачену на її виготовлення, турбіна повертає приблизно за 8 місяців. Її розраховано на 20 років, і часто вона служить довше, хоча лопаті досі важко переробляти. Виберіть тиху, звичайну чи вітряну місцевість і побачите, як багато важить звичайний щоденний вітер." + } + } +} From 24e1827c87739740338ce39f76ca3dc91bef387e Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:05:35 +0200 Subject: [PATCH 026/132] feat(wind-farm): add the Spanish locale --- explainers/wind-farm/locales/es.json | 269 +++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 explainers/wind-farm/locales/es.json diff --git a/explainers/wind-farm/locales/es.json b/explainers/wind-farm/locales/es.json new file mode 100644 index 00000000..f032428c --- /dev/null +++ b/explainers/wind-farm/locales/es.json @@ -0,0 +1,269 @@ +{ + "meta": { + "title": "Cómo funciona un parque eólico", + "eyebrow": "Energía renovable", + "tagline": "Veintisiete aerogeneradores y un día de viento: así es como una pala con forma de ala transforma el aire que se mueve en electricidad para la red.", + "description": "Un día de viento en un parque eólico 3D: cómo captan el aire las palas, qué gira en la góndola, por qué van tan separados y cómo llega la energía a la red.", + "summary": "La distribución del parque, la torre y las palas, la multiplicadora y el generador, la curva de potencia y el límite de Betz, las estelas y el camino hasta la red.", + "socialAlt": "Un parque eólico sobre colinas suaves, con un aerogenerador en primer plano, junto al título «Cómo funciona un parque eólico»" + }, + "stage": { + "label": "Modelo interactivo de un parque eólico", + "gauge": "Viento y potencia en directo" + }, + "timeline": { + "label": "Hora del día", + "phases": "Saltar a un momento del día", + "speed": "Velocidad de reproducción", + "value": "{{time}}", + "during": "{{time}}, {{phase}}", + "speedValue": "{{value}} h/min", + "speedFormat": "{{value}} horas del día por minuto", + "phase": { + "night": "Noche en calma", + "morning": "Brisa de la mañana", + "afternoon": "Plena potencia", + "storm": "Tormenta", + "evening": "Anochecer" + }, + "jump": { + "night": "Saltar a la noche en calma", + "morning": "Saltar a la brisa de la mañana", + "afternoon": "Saltar a plena potencia", + "storm": "Saltar a la tormenta", + "evening": "Saltar al anochecer" + } + }, + "controls": { + "label": "Controles del modelo", + "siteWind": "Viento de la zona", + "siteWindOptions": { + "calm": "Zona tranquila", + "typical": "Zona típica", + "windy": "Zona ventosa" + }, + "spacingOptions": { + "5": "5 D", + "7": "7 D", + "9": "9 D" + }, + "nacelleOptions": { + "closed": "Con cubierta", + "open": "Sin cubierta" + }, + "windAtOptions": { + "day": "Seguir el día", + "cutIn": "Arranque, 3 m/s", + "peak": "Mejor aprovechamiento, 8 m/s", + "rated": "Plena potencia, 12 m/s", + "rampDown": "Reduciendo potencia, 23 m/s", + "cutOut": "Por encima del corte, 25 m/s" + }, + "groups": { + "siteWind": "Viento de la zona", + "spacing": "Distancia entre filas", + "nacelle": "Cubierta de la góndola", + "windAt": "Velocidad del viento" + }, + "views": { + "streamlines": { + "name": "Flujo de aire", + "short": "Aire", + "hint": "Flujo de aire (F)" + }, + "wakes": { + "name": "Estelas", + "short": "Estelas", + "hint": "Estelas (K)" + }, + "cables": { + "name": "Cables", + "short": "Cables", + "hint": "Cables (C)" + }, + "labels": { + "name": "Etiquetas", + "short": "Etiquetas", + "hint": "Etiquetas (L)" + } + } + }, + "readouts": { + "wind": "Viento", + "power": "Un aerogenerador", + "farm": "Todo el parque", + "state": { + "idle": "Esperando viento", + "partial": "Carga parcial", + "full": "Plena potencia", + "rampDown": "Reduciendo potencia", + "stopping": "Palas en bandera, parando", + "parked": "Detenido", + "starting": "Arrancando" + } + }, + "units": { + "metresPerSecond": "{{value}} m/s", + "speedPair": "{{ms}} m/s, {{kmh}} km/h", + "rpm": "{{value}} rpm", + "degrees": "{{value}}°", + "mw": "{{value}} MW", + "mwh": "{{value}} MWh", + "percent": "{{value}} %", + "metres": "{{value}} m", + "seconds": "{{value}} s", + "count": "{{value}}", + "hectares": "{{value}} ha", + "squareKm": "{{value}} km²", + "volts": "{{value}} V", + "gramsPerKwh": "{{value}} g de CO2e por kWh", + "months": "{{value}} meses" + }, + "chapters": { + "farm": { + "count": "Aerogeneradores", + "rated": "Potencia máxima del parque", + "rows": "Entre filas, en la dirección del viento", + "across": "Entre aerogeneradores de una fila", + "spacingValue": "{{metres}} m, {{diameters}} D", + "built": "Terreno construido", + "project": "Superficie total del proyecto" + }, + "tower": { + "wind": "Viento a la altura del buje", + "rpm": "Velocidad del rotor", + "turn": "Una vuelta dura", + "stopped": "Rotor parado", + "tip": "Velocidad en la punta de la pala", + "height": "Altura de la punta", + "blade": "Longitud de la pala", + "swept": "Área barrida", + "sweptValue": "{{area}} m², unos {{pitches}} campos de fútbol" + }, + "nacelle": { + "shaft": "Eje principal", + "generator": "Generador", + "gear": "Relación de la multiplicadora", + "gearValue": "cerca de 1 a {{ratio}}", + "pitch": "Ángulo de paso", + "brake": "Freno de disco", + "brakeValue": { + "released": "Liberado", + "holding": "Sujetando el rotor" + }, + "volts": "Voltaje del generador" + }, + "curve": { + "wind": "Velocidad del viento", + "state": "Estado del aerogenerador", + "power": "Producción del aerogenerador", + "caption": "La potencia del viento, el límite de Betz y la producción de este aerogenerador, con vientos de 0 a 30 m/s", + "axisWind": "Velocidad del viento (m/s)", + "axisPower": "Potencia (MW)", + "lineWind": "Potencia del viento", + "lineBetz": "Límite de Betz", + "lineTurbine": "Este aerogenerador", + "windPower": "Potencia del viento", + "cp": "Parte que capta", + "betz": "Parte del límite de Betz", + "pitch": "Ángulo de paso" + }, + "wakes": { + "spacing": "Distancia entre filas", + "deficit": "Frenado por la estela en la fila siguiente", + "loss": "Pérdida actual del parque por estelas", + "top": "Viento en lo alto del rotor", + "bottom": "Viento en la parte baja del rotor" + }, + "grid": { + "output": "Producción actual del parque", + "energy": "Energía producida hoy", + "homes": "Hogares de EE. UU. que abastecería esta producción", + "capacity": "Factor de capacidad de este día", + "voltages": "Voltaje en cada etapa", + "voltagesValue": "{{generator}} V, {{collector}} kV, {{grid}} kV", + "annual": "Un aerogenerador en un año", + "annualHomes": "Hogares de EE. UU. que abastece un aerogenerador", + "carbon": "Huella de carbono", + "payback": "Amortización energética" + } + }, + "parts": { + "land": "Suelo", + "foundation": "Cimentación", + "tower": "Torre", + "transformer": "Transformador", + "towerCable": "Cable eléctrico", + "nacelle": "Góndola", + "cooler": "Refrigerador", + "bedplate": "Bancada", + "hub": "Buje", + "blades": "Palas", + "pitchCylinders": "Cilindros de paso", + "mainBearing": "Rodamiento principal", + "mainShaft": "Eje principal", + "gearbox": "Multiplicadora", + "brakeDisc": "Freno de disco", + "generator": "Generador", + "converter": "Convertidor", + "yawDrives": "Motores de orientación", + "sweptArea": "Área barrida", + "streamlinesGroup": "Flujo de aire", + "heroWake": "Estela", + "farmLand": "Tierras de cultivo", + "farmTurbines": "27 aerogeneradores", + "accessRoads": "Caminos de acceso", + "spacingMarker": "Separación entre filas", + "prevailingWind": "Viento dominante", + "windArrows": "Viento", + "shearProfile": "Viento según la altura", + "wakePlumes": "Estelas", + "collectorCables": "Cables colectores", + "substation": "Subestación", + "gridLine": "Línea de la red" + }, + "sections": { + "farm": { + "title": "Veintisiete aerogeneradores y un día de viento", + "p1": "Esto es un parque eólico: 27 aerogeneradores repartidos por campos ondulados. Todos son Vestas V150, y cada uno da 4,2 megavatios (MW) a plena potencia. Un megavatio es un millón de vatios, así que entre todos el parque puede producir unos 113 MW. Tiene el tamaño de Målarberget, en Suecia, un parque real con 27 de estos aerogeneradores que funciona desde 2021.", + "p2": "Los aerogeneradores se colocan en filas de cara al viento dominante, que es la dirección desde la que sopla el viento más a menudo. Aquí viene del oeste. Los ingenieros miden las distancias en diámetros del rotor, que se escriben D, y aquí un D son 150 m. Las filas están a 7 D unas de otras, es decir, a 1050 m, y los aerogeneradores de una misma fila, a 4 D, o 600 m. Cada fila está algo desplazada hacia un lado, así que ningún aerogenerador queda justo detrás de otro de la fila de delante.", + "p3": "Desde arriba parece que están muy juntos, pero casi todo el terreno sigue siendo campo. En realidad solo se construye sobre unas 0,3 hectáreas por megavatio, y la mayor parte son caminos de acceso, no bases de aerogeneradores. Una hectárea es un cuadrado de 100 m de lado. La electricidad viaja por cables colectores hasta una subestación, y desde allí sale hacia la red.", + "p4": "La distribución real de Målarberget no se ha publicado, así que la que ves es propia de este modelo. El modelo también repite en bucle un día de viento inventado. No es un día medio: reúne en 24 horas una noche en calma, una mañana de brisa, una tarde a plena potencia y una tormenta al atardecer. El reloj va cientos de veces más rápido que en la realidad, pero cada rotor gira a su velocidad real. Los caminos, los cables y las flechas del viento se dibujan más anchos de lo que son para que puedas verlos a kilómetros de distancia, y la luz no sigue al reloj." + }, + "tower": { + "title": "Palas que vuelan en círculo", + "p1": "De cerca, un solo aerogenerador es enorme. La torre de acero sube el buje a 105 m del suelo, y el rotor, que es el buje con sus tres palas, mide 150 m de lado a lado. En lo más alto de su giro, la punta de una pala llega a unos 180 m, casi el doble de alto que el Big Ben. Allí arriba el viento sopla más fuerte que cerca del suelo, como verás en el capítulo 5.", + "p2": "Cada pala mide unos 74 m, casi la envergadura de un Airbus A380, el avión de pasajeros más grande. Está hecha de fibra de carbono y fibra de vidrio unidas con epoxi, un pegamento plástico muy duro. Una pala no es una vela que el viento empuja. El viento pasa por ella como por el ala de un avión, y su forma curva crea sustentación, una fuerza que actúa de lado, en ángulo recto con el viento. La sustentación tira de cada pala y hace girar el rotor.", + "p3": "A toda velocidad, el rotor da solo unas 10 vueltas por minuto, o 10 rpm (revoluciones por minuto). Es una vuelta cada 6 segundos, y parece que va sin prisa. Pero las puntas recorren un círculo enorme, así que van a unos 80 m/s, cerca de 300 km/h. Arrastra el control del viento para ver cómo el rotor se acelera con él, o elige Seguir el día para que el viento vuelva a seguir la hora del día. En este modelo, las puntas van 8 veces más rápido que el viento hasta que el rotor llega a su velocidad máxima.", + "p4": "El círculo que recorren las palas es el área barrida: unos 17 700 m², lo que ocupan unos 2,5 campos de fútbol. Solo el viento que cruza ese círculo puede mover el rotor. Las líneas de aire alrededor del rotor son un dibujo, porque el aire de verdad es invisible. En este modelo, el transformador está al pie de la torre, porque no se ha publicado dónde lo lleva el V150. Por la misma razón, no se dibuja la ligera inclinación del rotor." + }, + "nacelle": { + "title": "Dentro de la góndola", + "p1": "La caja alargada que hay detrás del buje es la góndola, de 12,8 m de largo. Elige Sin cubierta para ver lo que hay dentro. Allí están las máquinas que convierten el giro lento y potente del rotor en electricidad. Todas se apoyan en la bancada, un pesado bastidor de hierro fundido, y un refrigerador en el techo se lleva su calor.", + "p2": "En el buje, cada pala va montada sobre un rodamiento para poder girar sobre sí misma. Tres cilindros de paso, uno por pala, las hacen girar con presión de aceite. El ángulo de la pala frente al viento es su ángulo de paso, y decide cuánto viento puede atrapar. Detrás del buje, el eje principal gira dentro de su rodamiento principal a la misma velocidad lenta que el rotor, unas 10 rpm.", + "p3": "Un generador necesita girar rápido, así que entre los dos hay una multiplicadora. Tiene dos etapas planetarias, en las que unos engranajes pequeños giran alrededor de uno central, y una etapa helicoidal, con dientes inclinados. Juntas hacen que el eje gire unas 140 veces más rápido, hasta unas 1485 rpm. El generador es de inducción, con bobinados de cobre y sin imanes. Toda su electricidad, a 800 voltios, pasa por el convertidor, que le da la forma que necesita la red.", + "p4": "En el eje hay un freno de disco. No es lo que detiene el aerogenerador en una tormenta: eso lo hacen las palas, que se ponen de canto al viento. El freno solo sujeta el rotor cuando ya gira despacio, para dejarlo quieto. En lo alto de la torre, los motores de orientación giran toda la góndola para que mire al viento. En este modelo la góndola sigue exactamente la dirección del viento, porque no se ha publicado a qué velocidad gira la del V150." + }, + "curve": { + "title": "Cuánto viento puede captar un aerogenerador", + "p1": "Por debajo de 3 m/s, la velocidad de arranque, el rotor espera. Por encima, la potencia sube deprisa, porque la potencia del viento crece con el cubo de la velocidad: velocidad por velocidad por velocidad. Si el viento se duplica, lleva ocho veces más potencia. Por eso el aerogenerador da unos 0,3 MW a 4 m/s, unos 2,5 MW a 8 m/s y sus 4,2 MW completos a partir de unos 12 m/s. Arrastra el control del viento y mira cómo avanza el punto por la curva.", + "p2": "Ningún aerogenerador puede captarla toda. Para sacar energía, el rotor tiene que frenar el aire, pero ese aire tiene que poder salir por detrás. El físico Albert Betz demostró que ni siquiera un rotor perfecto puede llevarse más del 59,3 % de la potencia. Es el límite de Betz. Lo mejor que consigue este aerogenerador es algo menos de la mitad, con vientos de unos 7 a 8 m/s, y la gráfica muestra lo cerca que se queda.", + "p3": "Por encima de la plena potencia, el viento trae más de lo que el generador puede aprovechar. Por eso los cilindros de paso giran las palas para sacarlas un poco del viento, y la mayor parte del aire pasa de largo. A 15 m/s, el aerogenerador se queda solo con cerca del 12 % de la potencia del viento. Los ángulos de paso que ves por encima de la plena potencia son propios de este modelo, porque los del V150 no se han publicado. A partir de 20 m/s, la potencia empieza a bajar.", + "p4": "A la velocidad de corte, entre unos 22 y 25 m/s, las palas se ponen de canto al viento. Es la puesta en bandera: el rotor se frena solo y después el freno de disco lo deja quieto. El aerogenerador no vuelve a arrancar hasta que el viento ha bajado unos 2 m/s. En este modelo, los 27 aerogeneradores se paran a la vez a 24,5 m/s y vuelven a arrancar a 22,5 m/s. La parada y el nuevo arranque duran cada uno 20 minutos en el reloj, más que en la realidad, para que puedas verlos." + }, + "wakes": { + "title": "Por qué los aerogeneradores están tan separados", + "p1": "Un aerogenerador le quita energía al viento, así que el aire que deja detrás va más lento. Esa cola de aire lento y revuelto se llama estela. Activa Estelas (K) para ver un penacho detrás de cada rotor que gira. Las estelas son más fuertes con viento moderado, que es cuando el rotor frena más el aire. Por encima de la plena potencia, las palas dejan pasar más viento y las estelas se desvanecen.", + "p2": "Este modelo dibuja las estelas con una receta sencilla y muy usada, el modelo de Jensen. Trata cada estela como un cono que se va ensanchando y poco a poco se vuelve a llenar de aire más rápido. Lo rápido que se ensancha depende de un solo número, aquí 0,075, el valor habitual en tierra. A 8 m/s, un aerogenerador que está 7 D por detrás, siguiendo el viento, todavía recibe aire alrededor de un 13 % más lento. Como la potencia depende del cubo de la velocidad del viento, eso le cuesta cerca de un tercio de su potencia.", + "p3": "A lo largo de un año, las estelas le cuestan a un parque entre un 5 y un 15 % de su energía, más o menos. Un parque de ejemplo de Vestas, con 24 de estos aerogeneradores, pierde cerca de un 6 %. Para una sola dirección del viento, la pérdida puede pasar del 30 %. Por eso las filas suelen estar separadas de 5 a 9 D en la dirección del viento y de 3 a 5 D a lo ancho. Prueba 5, 7 y 9 D: con más separación se pierde menos viento, pero hace falta más terreno.", + "p4": "El viento también aumenta con la altura, porque el suelo frena el aire que tiene cerca. Activa Flujo de aire (F) para ver el perfil: la parte alta del rotor recibe un viento más rápido que la baja. Los penachos y las flechas son dibujos de un aire que no se ve. En este modelo el suelo es llano, un rotor parado no deja estela y las estelas que se superponen se suman de forma sencilla." + }, + "grid": { + "title": "El camino hasta la red", + "p1": "Cada generador produce electricidad a 800 voltios (V). Después del convertidor, el transformador del propio aerogenerador sube el voltaje. Con más voltaje, la misma potencia viaja con menos corriente, así que se pierde menos en forma de calor en los cables. Activa Cables (C) para seguir la electricidad desde los aerogeneradores hasta la red.", + "p2": "Los cables colectores, a 36 000 voltios, o 36 kilovoltios (kV), unen los aerogeneradores con la subestación. Allí otro transformador sube el voltaje a 110 kV para la línea de la red. Estos voltajes son los de un parque de ejemplo de Vestas en Alemania.", + "p3": "A plena potencia, un aerogenerador produce 4,2 megavatios hora (MWh) cada hora. Pero el viento no siempre es fuerte, así que a lo largo del año un aerogenerador da de media cerca del 40 % de su plena potencia. Esa proporción es el factor de capacidad. En un año, un aerogenerador produce unos 14 700 MWh, suficiente para unos 1400 hogares de EE. UU. El día inventado del modelo tiene más viento que la media del año, así que su factor de capacidad sale más alto.", + "p4": "Fabricar un aerogenerador también cuesta energía y carbono: a lo largo de su vida, suma unos 7 g de CO2e por cada kilovatio hora (kWh) que produce. El CO2e cuenta todos los gases de efecto invernadero como si fueran dióxido de carbono. El aerogenerador recupera en unos 8 meses la energía que costó construirlo. Está diseñado para 20 años y a menudo funciona más tiempo, aunque sus palas siguen siendo difíciles de reciclar. Elige una zona tranquila, típica o ventosa para ver cuánto importa el viento de cada día." + } + } +} From c5d602499a620c60a3f5f4e20b670b563b955a55 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:05:40 +0200 Subject: [PATCH 027/132] feat(wind-farm): add the French locale --- explainers/wind-farm/locales/fr.json | 269 +++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 explainers/wind-farm/locales/fr.json diff --git a/explainers/wind-farm/locales/fr.json b/explainers/wind-farm/locales/fr.json new file mode 100644 index 00000000..17300d4e --- /dev/null +++ b/explainers/wind-farm/locales/fr.json @@ -0,0 +1,269 @@ +{ + "meta": { + "title": "Comment fonctionne un parc éolien", + "eyebrow": "Énergies renouvelables", + "tagline": "Vingt-sept éoliennes et une journée de vent : comment une pale en forme d’aile transforme le vent en électricité pour le réseau.", + "description": "Un jour de vent dans un parc éolien en 3D : les pales qui captent l’air, ce qui tourne dans la nacelle, l’écart entre éoliennes et la route vers le réseau.", + "summary": "Le plan du parc, le mât et les pales, le multiplicateur et la génératrice, la courbe de puissance et la limite de Betz, les sillages et le chemin vers le réseau.", + "socialAlt": "Un parc éolien sur des collines douces, avec une éolienne en gros plan, à côté du titre « Comment fonctionne un parc éolien »" + }, + "stage": { + "label": "Modèle interactif de parc éolien", + "gauge": "Vent et puissance en direct" + }, + "timeline": { + "label": "Heure de la journée", + "phases": "Aller à un moment de la journée", + "speed": "Vitesse de lecture", + "value": "{{time}}", + "during": "{{time}}, {{phase}}", + "speedValue": "{{value}} h/min", + "speedFormat": "{{value}} heures de la journée par minute", + "phase": { + "night": "Nuit calme", + "morning": "Brise du matin", + "afternoon": "Pleine puissance", + "storm": "Tempête", + "evening": "Soirée" + }, + "jump": { + "night": "Aller à la nuit calme", + "morning": "Aller à la brise du matin", + "afternoon": "Aller à la pleine puissance", + "storm": "Aller à la tempête", + "evening": "Aller à la soirée" + } + }, + "controls": { + "label": "Commandes du modèle", + "siteWind": "Vent du site", + "siteWindOptions": { + "calm": "Site calme", + "typical": "Site moyen", + "windy": "Site venté" + }, + "spacingOptions": { + "5": "5 D", + "7": "7 D", + "9": "9 D" + }, + "nacelleOptions": { + "closed": "Avec capot", + "open": "Sans capot" + }, + "windAtOptions": { + "day": "Suivre la journée", + "cutIn": "Démarrage, 3 m/s", + "peak": "Meilleur rendement, 8 m/s", + "rated": "Pleine puissance, 12 m/s", + "rampDown": "Baisse progressive, 23 m/s", + "cutOut": "Après la coupure, 25 m/s" + }, + "groups": { + "siteWind": "Vent du site", + "spacing": "Écart entre les rangées", + "nacelle": "Capot de la nacelle", + "windAt": "Vitesse du vent" + }, + "views": { + "streamlines": { + "name": "Flux d’air", + "short": "Air", + "hint": "Flux d’air (F)" + }, + "wakes": { + "name": "Sillages", + "short": "Sillages", + "hint": "Sillages (K)" + }, + "cables": { + "name": "Câbles", + "short": "Câbles", + "hint": "Câbles (C)" + }, + "labels": { + "name": "Étiquettes", + "short": "Étiquettes", + "hint": "Étiquettes (L)" + } + } + }, + "readouts": { + "wind": "Vent", + "power": "Une éolienne", + "farm": "Tout le parc", + "state": { + "idle": "En attente de vent", + "partial": "Charge partielle", + "full": "Pleine puissance", + "rampDown": "Baisse progressive", + "stopping": "Mise en drapeau", + "parked": "À l’arrêt", + "starting": "Démarrage" + } + }, + "units": { + "metresPerSecond": "{{value}} m/s", + "speedPair": "{{ms}} m/s, {{kmh}} km/h", + "rpm": "{{value}} tr/min", + "degrees": "{{value}}°", + "mw": "{{value}} MW", + "mwh": "{{value}} MWh", + "percent": "{{value}} %", + "metres": "{{value}} m", + "seconds": "{{value}} s", + "count": "{{value}}", + "hectares": "{{value}} ha", + "squareKm": "{{value}} km²", + "volts": "{{value}} V", + "gramsPerKwh": "{{value}} g de CO2e par kWh", + "months": "{{value}} mois" + }, + "chapters": { + "farm": { + "count": "Éoliennes", + "rated": "Pleine puissance du parc", + "rows": "Entre les rangées, dans l’axe du vent", + "across": "Entre les éoliennes d’une rangée", + "spacingValue": "{{metres}} m, {{diameters}} D", + "built": "Surface construite", + "project": "Surface totale du projet" + }, + "tower": { + "wind": "Vent à hauteur du moyeu", + "rpm": "Vitesse du rotor", + "turn": "Durée d’un tour", + "stopped": "Rotor à l’arrêt", + "tip": "Vitesse en bout de pale", + "height": "Hauteur en bout de pale", + "blade": "Longueur d’une pale", + "swept": "Surface balayée", + "sweptValue": "{{area}} m², environ {{pitches}} terrains de football" + }, + "nacelle": { + "shaft": "Arbre principal", + "generator": "Génératrice", + "gear": "Rapport de multiplication", + "gearValue": "environ 1 pour {{ratio}}", + "pitch": "Calage des pales", + "brake": "Frein à disque", + "brakeValue": { + "released": "Desserré", + "holding": "Retient le rotor" + }, + "volts": "Tension de la génératrice" + }, + "curve": { + "wind": "Vitesse du vent", + "state": "État de l’éolienne", + "power": "Production de l’éolienne", + "caption": "La puissance du vent, la limite de Betz et la production de cette éolienne, pour des vents de 0 à 30 m/s", + "axisWind": "Vitesse du vent (m/s)", + "axisPower": "Puissance (MW)", + "lineWind": "Puissance du vent", + "lineBetz": "Limite de Betz", + "lineTurbine": "Cette éolienne", + "windPower": "Puissance du vent", + "cp": "Part captée", + "betz": "Part de la limite de Betz", + "pitch": "Calage des pales" + }, + "wakes": { + "spacing": "Écart entre les rangées", + "deficit": "Ralentissement dû au sillage à la rangée suivante", + "loss": "Pertes dues aux sillages en ce moment", + "top": "Vent en haut du rotor", + "bottom": "Vent en bas du rotor" + }, + "grid": { + "output": "Production du parc en ce moment", + "energy": "Énergie produite aujourd’hui", + "homes": "Foyers américains que cette puissance pourrait alimenter", + "capacity": "Facteur de charge de cette journée", + "voltages": "Tension à chaque étape", + "voltagesValue": "{{generator}} V, {{collector}} kV, {{grid}} kV", + "annual": "Une éolienne en un an", + "annualHomes": "Foyers américains alimentés par une éolienne", + "carbon": "Empreinte carbone", + "payback": "Temps de retour énergétique" + } + }, + "parts": { + "land": "Sol", + "foundation": "Fondation", + "tower": "Mât", + "transformer": "Transformateur", + "towerCable": "Câble électrique", + "nacelle": "Nacelle", + "cooler": "Refroidisseur", + "bedplate": "Châssis", + "hub": "Moyeu", + "blades": "Pales", + "pitchCylinders": "Vérins de calage", + "mainBearing": "Roulement principal", + "mainShaft": "Arbre principal", + "gearbox": "Multiplicateur", + "brakeDisc": "Frein à disque", + "generator": "Génératrice", + "converter": "Convertisseur", + "yawDrives": "Moteurs d’orientation", + "sweptArea": "Surface balayée", + "streamlinesGroup": "Flux d’air", + "heroWake": "Sillage", + "farmLand": "Terres agricoles", + "farmTurbines": "27 éoliennes", + "accessRoads": "Chemins d’accès", + "spacingMarker": "Écart entre les rangées", + "prevailingWind": "Vent dominant", + "windArrows": "Vent", + "shearProfile": "Vent selon la hauteur", + "wakePlumes": "Sillages", + "collectorCables": "Câbles de collecte", + "substation": "Poste électrique", + "gridLine": "Ligne du réseau" + }, + "sections": { + "farm": { + "title": "Vingt-sept éoliennes et une journée de vent", + "p1": "Voici un parc éolien : 27 éoliennes réparties sur des champs vallonnés. Ce sont toutes des Vestas V150, et chacune produit 4,2 mégawatts (MW) à pleine puissance. Un mégawatt, c’est un million de watts. Au total, le parc peut donc fournir environ 113 MW. Il a la taille de Målarberget, en Suède : un vrai parc de 27 de ces éoliennes en service depuis 2021.", + "p2": "Les éoliennes forment des rangées placées en travers du vent dominant, c’est-à-dire la direction d’où le vent souffle le plus souvent. Ici, il vient de l’ouest. Les ingénieurs mesurent les écarts en diamètres de rotor, notés D, et un D mesure 150 m pour la V150. Les rangées sont à 7 D les unes des autres, soit 1 050 m, et dans une rangée, les éoliennes sont à 4 D, soit 600 m. Chaque rangée est décalée sur le côté, pour qu’aucune éolienne ne se trouve juste derrière une autre de la rangée de devant.", + "p3": "Vu d’en haut, le parc paraît encombré, pourtant l’essentiel du terrain reste en champs. On ne construit qu’environ 0,3 hectare par mégawatt, et ce sont surtout des chemins d’accès, pas des socles d’éoliennes. Un hectare, c’est un carré de 100 m de côté. Le courant passe par des câbles de collecte jusqu’à un poste électrique, d’où il part vers le réseau.", + "p4": "Le vrai plan de Målarberget n’est pas publié : celui que vous voyez est propre à ce modèle. Le modèle rejoue aussi en boucle une journée de vent inventée. Ce n’est pas une journée moyenne : elle réunit en 24 heures une nuit calme, une matinée de brise, un après-midi à pleine puissance et une tempête le soir. L’horloge va des centaines de fois plus vite que la réalité, mais chaque rotor tourne à sa vraie vitesse. Les routes, les câbles et les flèches de vent sont dessinés plus larges que nature pour rester visibles à des kilomètres, et la lumière ne suit pas l’horloge." + }, + "tower": { + "title": "Des pales qui volent en rond", + "p1": "De près, une éolienne est immense. Le mât en acier porte le moyeu à 105 m du sol, et le rotor, c’est-à-dire le moyeu et ses trois pales, mesure 150 m de diamètre. En haut de sa course, le bout d’une pale atteint environ 180 m, presque deux fois la hauteur de Big Ben. Là-haut, le vent souffle plus vite qu’au ras du sol, comme le montre le chapitre 5.", + "p2": "Chaque pale mesure environ 74 m, presque l’envergure d’un Airbus A380, le plus gros avion de ligne. Elle est faite de fibres de carbone et de verre noyées dans de l’époxy, une colle plastique très dure. Une pale n’est pas une voile que le vent pousse. L’air glisse dessus comme sur l’aile d’un avion, et sa forme bombée crée une portance, une force qui agit en travers du vent. La portance entraîne chaque pale, et le rotor se met à tourner.", + "p3": "À pleine vitesse, le rotor ne fait qu’environ 10 tours par minute, ce qu’on note 10 tr/min. Un tour toutes les 6 secondes : cela paraît bien paresseux. Mais le bout des pales parcourt un cercle immense, si bien qu’il file à environ 80 m/s, près de 300 km/h. Faites glisser le curseur du vent pour voir le rotor accélérer avec lui, ou choisissez « Suivre la journée » pour que le vent suive à nouveau l’horloge. Dans ce modèle, le bout des pales va 8 fois plus vite que le vent, jusqu’à ce que le rotor atteigne sa vitesse maximale.", + "p4": "Le cercle que balaient les pales, c’est la surface balayée : environ 17 700 m², soit à peu près 2,5 terrains de football. Seul le vent qui traverse ce cercle peut faire tourner le rotor. Les lignes d’air autour du rotor sont dessinées pour vous, car en vrai, l’air est invisible. Dans ce modèle, le transformateur se trouve au pied du mât, car son emplacement réel sur la V150 n’est pas publié. Pour la même raison, la légère inclinaison du rotor n’est pas dessinée." + }, + "nacelle": { + "title": "Dans la nacelle", + "p1": "Le grand caisson derrière le moyeu, c’est la nacelle, qui mesure 12,8 m de long. Choisissez « Sans capot » pour voir l’intérieur. Elle abrite les machines qui transforment la rotation lente et puissante du rotor en électricité. Toutes reposent sur le châssis, un lourd cadre en fonte, et un refroidisseur sur le toit évacue leur chaleur.", + "p2": "Dans le moyeu, chaque pale est montée sur un roulement qui lui permet de pivoter. Trois vérins de calage, un par pale, les font pivoter grâce à la pression d’huile. L’angle de la pale face au vent s’appelle le calage, et il règle la prise que le vent a sur elle. Derrière le moyeu, l’arbre principal tourne dans son roulement principal à la même vitesse lente que le rotor, environ 10 tr/min.", + "p3": "Une génératrice doit tourner vite : un multiplicateur s’intercale donc entre l’arbre et elle. Il compte deux étages planétaires, où de petits engrenages tournent autour d’un engrenage central, et un étage hélicoïdal aux dents inclinées. Ensemble, ils multiplient la vitesse de l’arbre par environ 140, jusqu’à environ 1 485 tr/min. La génératrice est de type asynchrone : elle a des bobinages en cuivre et pas d’aimants. Toute sa puissance, sous 800 volts, passe par le convertisseur, qui la remet en forme pour l’adapter au réseau.", + "p4": "Sur l’arbre se trouve un frein à disque. Ce n’est pas lui qui arrête l’éolienne pendant une tempête : ce sont les pales, en présentant leur tranche au vent. Le frein sert seulement à retenir le rotor une fois qu’il a ralenti, pour le garder à l’arrêt. En haut du mât, des moteurs d’orientation tournent toute la nacelle face au vent. Dans ce modèle, elle suit exactement le vent, car la vitesse réelle à laquelle la V150 pivote n’est pas publiée." + }, + "curve": { + "title": "Quelle part du vent une éolienne peut capter", + "p1": "Sous 3 m/s, la vitesse de démarrage, le rotor attend. Au-delà, la puissance grimpe vite, car la puissance du vent croît avec le cube de sa vitesse : vitesse fois vitesse fois vitesse. Quand le vent double, sa puissance est multipliée par huit. L’éolienne produit donc environ 0,3 MW à 4 m/s, environ 2,5 MW à 8 m/s, et la totalité de ses 4,2 MW à partir d’environ 12 m/s. Faites glisser le curseur du vent et regardez le point avancer sur la courbe.", + "p2": "Aucune éolienne ne peut tout capter. Pour prendre de l’énergie, le rotor doit freiner l’air, mais cet air doit quand même pouvoir s’échapper derrière lui. Le physicien Albert Betz a montré que même un rotor parfait ne peut pas prendre plus de 59,3 % de la puissance. C’est la limite de Betz. Cette éolienne en capte au mieux un peu moins de la moitié, vers 7 à 8 m/s, et le graphique montre à quel point elle s’approche de la limite.", + "p3": "Une fois la pleine puissance atteinte, le vent apporte plus que la génératrice ne peut en prendre. Les vérins de calage tournent donc un peu les pales hors du vent, et l’essentiel de l’air passe sans être capté. À 15 m/s, l’éolienne ne garde qu’environ 12 % de la puissance du vent. Les angles de calage affichés à ce stade sont propres à ce modèle, car ceux de la V150 ne sont pas publiés. Au-delà de 20 m/s, la puissance commence à baisser progressivement.", + "p4": "À la vitesse de coupure, environ 22 à 25 m/s, les pales présentent leur tranche au vent. C’est la mise en drapeau : le rotor ralentit tout seul, puis le frein à disque l’immobilise. L’éolienne ne redémarre qu’une fois que le vent a baissé d’environ 2 m/s. Dans ce modèle, les 27 éoliennes s’arrêtent ensemble à 24,5 m/s et repartent à 22,5 m/s. L’arrêt et le redémarrage prennent chacun 20 minutes sur l’horloge, plus qu’en réalité, pour que vous ayez le temps de les observer." + }, + "wakes": { + "title": "Pourquoi les éoliennes sont si espacées", + "p1": "Une éolienne prend de l’énergie au vent, donc l’air ralentit derrière elle. Cette traînée d’air lent et brassé s’appelle un sillage. Activez « Sillages (K) » pour voir un panache derrière chaque rotor qui tourne. Les sillages sont les plus forts par vent modéré, quand le rotor retient l’air le plus fortement. Une fois la pleine puissance atteinte, les pales laissent filer davantage de vent, et les sillages s’estompent.", + "p2": "Ce modèle dessine les sillages avec une recette simple et très répandue, le modèle de Jensen. Il traite chaque sillage comme un cône qui s’élargit et se remplit peu à peu d’air plus rapide. La vitesse à laquelle il s’élargit dépend d’un seul nombre, ici 0,075, la valeur habituelle pour les parcs à terre. À 8 m/s, une éolienne placée 7 D plus loin sous le vent reçoit encore un air environ 13 % plus lent. Comme la puissance suit le cube du vent, cela lui coûte environ un tiers de sa puissance.", + "p3": "Sur une année, les sillages font perdre à un parc environ 5 à 15 % de son énergie. Dans un exemple de parc fourni par Vestas, avec 24 de ces éoliennes, la perte est d’environ 6 %. Pour une seule direction de vent, elle peut dépasser 30 %. C’est pourquoi les rangées sont en général espacées de 5 à 9 D dans l’axe du vent, et de 3 à 5 D en travers. Essayez 5, 7 et 9 D : des écarts plus larges perdent moins de vent, mais demandent plus de terrain.", + "p4": "Le vent forcit aussi avec la hauteur, car le sol freine l’air qui le touche. Activez « Flux d’air (F) » pour voir ce profil : le haut du rotor reçoit un vent plus rapide que le bas. Les panaches et les flèches ne sont que des dessins, car l’air, lui, est invisible. Dans ce modèle, le sol est plat, un rotor arrêté ne laisse pas de sillage, et les sillages qui se chevauchent s’additionnent de façon simple." + }, + "grid": { + "title": "Le chemin jusqu’au réseau", + "p1": "Chaque génératrice produit du courant sous 800 volts (V). Après le convertisseur, le transformateur de l’éolienne élève la tension. Une tension plus haute transporte la même puissance avec moins de courant, donc les câbles perdent moins d’énergie en chaleur. Activez « Câbles (C) » pour suivre le courant des éoliennes jusqu’au réseau.", + "p2": "Des câbles de collecte à 36 000 volts, soit 36 kilovolts (kV), relient les éoliennes au poste électrique. Là, un autre transformateur élève la tension à 110 kV pour la ligne du réseau. Ces tensions viennent d’un exemple de parc en Allemagne, fourni par Vestas.", + "p3": "À pleine puissance, une éolienne produit 4,2 mégawattheures (MWh) chaque heure. Mais le vent n’est pas toujours fort : sur un an, une éolienne fournit en moyenne environ 40 % de sa pleine puissance. Cette part s’appelle le facteur de charge. En un an, une éolienne produit environ 14 700 MWh, assez pour environ 1 400 foyers américains. La journée inventée du modèle est plus venteuse que la moyenne de l’année, si bien que son facteur de charge est plus élevé.", + "p4": "Fabriquer une éolienne coûte aussi de l’énergie et du carbone : sur toute sa durée de vie, elle représente environ 7 g de CO2e par kilowattheure (kWh) produit. Le CO2e compte tous les gaz à effet de serre comme s’ils étaient du dioxyde de carbone. L’éolienne rembourse en environ 8 mois l’énergie qui a servi à la construire. Elle est conçue pour 20 ans et tourne souvent plus longtemps, même si ses pales restent difficiles à recycler. Choisissez un site calme, moyen ou venté pour voir à quel point le vent de tous les jours compte." + } + } +} From aeafa5bd37d80f2c56941ed96c4722f86990d583 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:05:41 +0200 Subject: [PATCH 028/132] feat(wind-farm): add the Japanese locale --- explainers/wind-farm/locales/ja.json | 269 +++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 explainers/wind-farm/locales/ja.json diff --git a/explainers/wind-farm/locales/ja.json b/explainers/wind-farm/locales/ja.json new file mode 100644 index 00000000..4d856422 --- /dev/null +++ b/explainers/wind-farm/locales/ja.json @@ -0,0 +1,269 @@ +{ + "meta": { + "title": "風力発電所のしくみ", + "eyebrow": "再生可能エネルギー", + "tagline": "27基の風車と、風の強い一日。翼の形をしたブレードが、動く空気を電気に変えて送電網へ送るしくみです。", + "description": "風の強い一日を3Dの風力発電所で。ブレードが風をとらえるしくみ、ナセルの中で回るもの、風車どうしが離れている理由、電気が送電網に届くまでを見ていきます。", + "summary": "発電所の配置、タワーとブレード、増速機と発電機、パワーカーブとベッツの限界、後流、そして送電網までの道のり。", + "socialAlt": "なだらかな丘に広がる風力発電所と、手前に大きく写る風車1基、その横に「風力発電所のしくみ」というタイトル" + }, + "stage": { + "label": "風力発電所のインタラクティブモデル", + "gauge": "いまの風と出力" + }, + "timeline": { + "label": "時刻", + "phases": "一日の場面へ移動", + "speed": "再生速度", + "value": "{{time}}", + "during": "{{time}}、{{phase}}", + "speedValue": "{{value}}時間/分", + "speedFormat": "1分あたり一日の{{value}}時間分", + "phase": { + "night": "風の弱い夜", + "morning": "朝のそよ風", + "afternoon": "フル出力", + "storm": "嵐", + "evening": "夕方" + }, + "jump": { + "night": "風の弱い夜へ移動", + "morning": "朝のそよ風へ移動", + "afternoon": "フル出力へ移動", + "storm": "嵐へ移動", + "evening": "夕方へ移動" + } + }, + "controls": { + "label": "モデルの操作", + "siteWind": "設置場所の風", + "siteWindOptions": { + "calm": "風の弱い場所", + "typical": "ふつうの場所", + "windy": "風の強い場所" + }, + "spacingOptions": { + "5": "5D", + "7": "7D", + "9": "9D" + }, + "nacelleOptions": { + "closed": "カバーあり", + "open": "カバーなし" + }, + "windAtOptions": { + "day": "一日の流れどおり", + "cutIn": "カットイン(3m/s)", + "peak": "最高効率(8m/s)", + "rated": "フル出力(12m/s)", + "rampDown": "出力を絞る(23m/s)", + "cutOut": "カットアウト超え(25m/s)" + }, + "groups": { + "siteWind": "設置場所の風", + "spacing": "列の間隔", + "nacelle": "ナセルのカバー", + "windAt": "風速" + }, + "views": { + "streamlines": { + "name": "気流", + "short": "気流", + "hint": "気流(F)" + }, + "wakes": { + "name": "後流", + "short": "後流", + "hint": "後流(K)" + }, + "cables": { + "name": "ケーブル", + "short": "ケーブル", + "hint": "ケーブル(C)" + }, + "labels": { + "name": "ラベル", + "short": "ラベル", + "hint": "ラベル(L)" + } + } + }, + "readouts": { + "wind": "風", + "power": "風車1基", + "farm": "発電所全体", + "state": { + "idle": "風待ち", + "partial": "部分負荷", + "full": "フル出力", + "rampDown": "出力を絞り中", + "stopping": "フェザリングで停止中", + "parked": "停止", + "starting": "起動中" + } + }, + "units": { + "metresPerSecond": "{{value}}m/s", + "speedPair": "{{ms}}m/s、時速{{kmh}}km", + "rpm": "{{value}}rpm", + "degrees": "{{value}}°", + "mw": "{{value}}MW", + "mwh": "{{value}}MWh", + "percent": "{{value}}%", + "metres": "{{value}}m", + "seconds": "{{value}}秒", + "count": "{{value}}", + "hectares": "{{value}}ha", + "squareKm": "{{value}}km²", + "volts": "{{value}}V", + "gramsPerKwh": "1kWhあたり{{value}}g(CO2e換算)", + "months": "{{value}}か月" + }, + "chapters": { + "farm": { + "count": "風車の数", + "rated": "発電所のフル出力", + "rows": "列の間隔(風の向き)", + "across": "同じ列の風車の間隔", + "spacingValue": "{{metres}}m、{{diameters}}D", + "built": "実際に建設に使う土地", + "project": "発電所の区域全体" + }, + "tower": { + "wind": "ハブの高さの風", + "rpm": "ローターの回転数", + "turn": "1回転にかかる時間", + "stopped": "ローター停止中", + "tip": "ブレード先端の速さ", + "height": "ブレード先端の最高点", + "blade": "ブレードの長さ", + "swept": "受風面積", + "sweptValue": "{{area}}m²、サッカー場およそ{{pitches}}面分" + }, + "nacelle": { + "shaft": "主軸", + "generator": "発電機", + "gear": "ギア比", + "gearValue": "約1対{{ratio}}", + "pitch": "ブレードのピッチ角", + "brake": "ディスクブレーキ", + "brakeValue": { + "released": "解除", + "holding": "ローターを固定中" + }, + "volts": "発電機の電圧" + }, + "curve": { + "wind": "風速", + "state": "風車の状態", + "power": "風車の出力", + "caption": "風速0から30m/sまでの、風のもつパワー、ベッツの限界、この風車の出力", + "axisWind": "風速(m/s)", + "axisPower": "出力(MW)", + "lineWind": "風のもつパワー", + "lineBetz": "ベッツの限界", + "lineTurbine": "この風車", + "windPower": "風のもつパワー", + "cp": "取り出せる割合", + "betz": "ベッツの限界に対する割合", + "pitch": "ブレードのピッチ角" + }, + "wakes": { + "spacing": "列の間隔", + "deficit": "次の列での後流による減速", + "loss": "いま後流で失っている出力", + "top": "ローター上端の風", + "bottom": "ローター下端の風" + }, + "grid": { + "output": "いまの発電所の出力", + "energy": "今日の発電量", + "homes": "この出力でまかなえる米国の世帯数", + "capacity": "この日の設備利用率", + "voltages": "各段階の電圧", + "voltagesValue": "{{generator}}V、{{collector}}kV、{{grid}}kV", + "annual": "風車1基の年間発電量", + "annualHomes": "風車1基でまかなえる米国の世帯数", + "carbon": "カーボンフットプリント", + "payback": "エネルギー回収期間" + } + }, + "parts": { + "land": "地面", + "foundation": "基礎", + "tower": "タワー", + "transformer": "変圧器", + "towerCable": "電力ケーブル", + "nacelle": "ナセル", + "cooler": "冷却器", + "bedplate": "ベッドプレート", + "hub": "ハブ", + "blades": "ブレード", + "pitchCylinders": "ピッチシリンダー", + "mainBearing": "主軸受", + "mainShaft": "主軸", + "gearbox": "増速機", + "brakeDisc": "ディスクブレーキ", + "generator": "発電機", + "converter": "コンバーター", + "yawDrives": "ヨー駆動装置", + "sweptArea": "受風面積", + "streamlinesGroup": "気流", + "heroWake": "後流", + "farmLand": "農地", + "farmTurbines": "27基の風車", + "accessRoads": "管理用道路", + "spacingMarker": "列の間隔", + "prevailingWind": "卓越風", + "windArrows": "風", + "shearProfile": "高さごとの風", + "wakePlumes": "後流", + "collectorCables": "集電ケーブル", + "substation": "変電所", + "gridLine": "送電線" + }, + "sections": { + "farm": { + "title": "27基の風車と、風の強い一日", + "p1": "これは、なだらかな畑に27基の風車が並ぶ風力発電所です。どれもヴェスタスのV150で、フル出力では1基で4.2メガワット(MW)を発電します。メガワットは100万ワットのことで、発電所全体ではおよそ113MWになります。規模は、スウェーデンにある実在の発電所モーラルベリエットと同じです。そこでも同じ風車27基が2021年から動いています。", + "p2": "風車は列をつくり、卓越風を横切るように並んでいます。卓越風とはいちばんよく吹いてくる風の向きのことで、ここでは西風です。技術者は間隔を「ローターの直径いくつ分か」で表し、1つ分をDと書きます。ここでは1Dが150mで、列どうしは7D(1,050m)、同じ列の風車どうしは4D(600m)離れています。列ごとに横へ少しずらしてあるので、前の列の風車の真後ろに立つ風車はありません。", + "p3": "上から見るとぎっしり並んで見えますが、土地のほとんどは今も畑のままです。実際に建設に使うのは1メガワットあたり約0.3ヘクタールだけで、その大半は風車の土台ではなく管理用道路です。1ヘクタールは、1辺100mの正方形の広さです。電気は集電ケーブルを通って変電所に集まり、そこから送電網へ向かいます。", + "p4": "モーラルベリエットの実際の配置は公開されていないので、ここに見える配置はこのモデル独自のものです。モデルでは、架空の一日の風をくり返し流しています。平均的な一日ではなく、風の弱い夜、そよ風の朝、フル出力の午後、夕方の嵐を24時間に詰めこんだ一日です。時計は実際の何百倍もの速さで進みますが、ローターはどれも本当の速さで回ります。道路やケーブル、風の矢印は何kmも先から見えるよう実物より太く描いていて、日の光は時刻に合わせて変わりません。" + }, + "tower": { + "title": "円を描いて飛ぶブレード", + "p1": "近づいてみると、風車1基でも見上げるほどの大きさです。鋼鉄のタワーがハブを地上105mまで持ち上げ、ハブと3枚のブレードからなるローターは直径150mあります。回転のいちばん上では、ブレードの先端が約180mに届き、ビッグ・ベンのほぼ2倍の高さになります。第5章で見るように、その高さでは地面の近くより風が速く吹いています。", + "p2": "ブレードは1枚が約74mあり、最大の旅客機エアバスA380の翼幅とほぼ同じ長さです。炭素繊維とガラス繊維を、硬いプラスチックの接着剤であるエポキシ樹脂で固めてつくられています。ブレードは、風に押される帆ではありません。風は飛行機の翼の上を流れるようにブレードの表面を流れ、曲がった形が揚力、つまり風を横切る向きの力を生みます。この揚力が1枚1枚のブレードを引っぱり、ローターが回ります。", + "p3": "全速でも、ローターは1分間に約10回転、つまり10rpm(毎分回転数)しか回りません。6秒に1回転なので、のんびりして見えます。でも先端は大きな円を描くので、その速さは約80m/s、時速300km近くになります。風速スライダーを動かすと風に合わせてローターが速くなり、「一日の流れどおり」を選ぶと、風はまた時計に合わせて変わります。このモデルでは、ローターが最高速度に達するまで、先端は風の8倍の速さで動きます。", + "p4": "ブレードが回って描く円を受風面積といい、約17,700m²、サッカー場およそ2.5面分の広さです。ローターを回せるのは、この円を通り抜ける風だけです。本物の空気は目に見えないので、ローターのまわりの空気の線は図として描いたものです。V150の変圧器がどこにあるかは公開されていないため、このモデルではタワーの足もとに置いています。同じ理由で、ローターのわずかな傾きも描いていません。" + }, + "nacelle": { + "title": "ナセルの中", + "p1": "ハブの後ろにある長い箱がナセルで、長さは12.8mあります。「カバーなし」を選ぶと、中をのぞけます。中には、ローターのゆっくりした力強い回転を電気に変える機械が詰まっています。機械はどれも重い鋳鉄の台であるベッドプレートに載っていて、屋根の冷却器がその熱を外へ逃がします。", + "p2": "ハブの中では、ブレードが1枚ずつ軸受に取りつけられていて、ひねるように向きを変えられます。ブレード1枚に1本ずつ、計3本のピッチシリンダーが油圧でブレードをひねります。風に対するブレードの角度をピッチ角といい、風がどれだけブレードをつかめるかはこれで決まります。ハブの後ろでは主軸が主軸受の中で、ローターと同じ約10rpmのゆっくりした速さで回っています。", + "p3": "発電機は速く回す必要があるので、あいだに増速機(ギアボックス)が入っています。中には、中心の歯車のまわりを小さな歯車が回る遊星歯車の段が2つと、歯がななめについたはすば歯車の段が1つあります。これらを合わせて回転を約140倍に速め、最大で約1,485rpmにします。発電機は誘導発電機で、銅線を巻いたコイルを使い、磁石は使いません。800ボルトでつくられた電気はすべてコンバーターを通り、送電網に合う形に整えられます。", + "p4": "軸にはディスクブレーキがついています。でも嵐のときに風車を止めるのはこのブレーキではなく、縁を風に向けたブレードです。ブレーキは、回転が遅くなったローターを止めておくためだけに使います。タワーのてっぺんではヨー駆動装置がナセルごと回して、風の来る方へ向けます。V150が実際にどれくらいの速さで向きを変えるかは公開されていないため、このモデルではナセルが風にぴったりついていきます。" + }, + "curve": { + "title": "風車はどれだけ風をつかめるか", + "p1": "カットイン風速の3m/sまでローターは待機し、それを超えると出力が一気に伸びます。風のもつパワーは風速の3乗、つまり風速×風速×風速で増えるからです。風が2倍になれば、運ぶパワーは8倍です。そのため風車の出力は、4m/sで約0.3MW、8m/sで約2.5MW、約12m/sからはフル出力の4.2MWになります。風速スライダーを動かして、点がカーブの上を動くのを見てみましょう。", + "p2": "風のパワーをすべてとらえられる風車はありません。エネルギーを取り出すにはローターが空気を減速させる必要がありますが、それでも空気は後ろへ流れ出ていかなければなりません。物理学者のアルベルト・ベッツは、完璧なローターでも取り出せるのはパワーの59.3%までだと示し、これをベッツの限界といいます。この風車がいちばんよく取り出せるのは約7から8m/sのときで、その割合は半分弱です。どこまで限界に近づくかはグラフでわかります。", + "p3": "フル出力を超えると、風は発電機が受けとめきれないほどのパワーを運んできます。そこでピッチシリンダーがブレードを少し風からそらし、風の大部分を素通りさせます。15m/sでは、風車が使うのは風のパワーのわずか約12%です。V150の値は公開されていないため、フル出力より上で表示しているピッチ角はこのモデル独自のものです。20m/sを超えると、出力は少しずつ下がりはじめます。", + "p4": "約22から25m/sのカットアウト風速になると、ブレードは縁を風に向けます。これをフェザリングといい、ローターは自然に減速して、最後にディスクブレーキが止めます。風が約2m/s弱まるまで、風車は再び動き出しません。このモデルでは、27基すべてが24.5m/sでいっせいに止まり、22.5m/sで動き出します。止まるのも動き出すのもモデルの時計でそれぞれ20分かかり、実際より長くしてあるので、じっくり見られます。" + }, + "wakes": { + "title": "風車どうしがこれほど離れている理由", + "p1": "風車は風からエネルギーを取り出すので、その後ろの空気は遅くなります。この遅くかき乱された空気の流れを後流(ウェイク)と呼びます。「後流(K)」をオンにすると、回っているローターすべての後ろに伸びる帯が見えます。後流がいちばん強くなるのは中くらいの風のときで、ローターが空気をもっとも強く押しとどめるからです。フル出力を超えるとブレードが風を多く素通りさせるので、後流は弱まります。", + "p2": "このモデルは、広く使われているシンプルな計算方法のジェンセンモデルで後流を描いています。後流を、広がりながら周りの速い空気で少しずつ埋め戻されていく円すいとして扱う方法です。広がる速さは1つの数字で決まり、ここでは陸上でふつう使われる0.075です。風速8m/sのとき、7D風下の風車にはまだ約13%遅い風が届きます。出力は風速の3乗で決まるので、それだけでその風車は出力の約3分の1を失います。", + "p3": "1年を通すと、後流によって発電所は発電量の約5から15%を失います。ヴェスタスが例として示す、この風車24基の発電所では、損失は約6%です。ある1つの風向きだけで見ると、損失は30%を超えることもあります。そのため列の間隔はふつう、風の向きに5から9D、風を横切る向きに3から5Dとります。5D、7D、9Dを試すと、間隔が広いほど失う風は減るものの、そのぶん広い土地が必要になるのがわかります。", + "p4": "風は高いところほど速くなります。地面が近くの空気にブレーキをかけるからです。「気流(F)」をオンにすると高さごとの風の変化が見え、ローターの上端には下端より速い風が当たっているのがわかります。帯や矢印は、目に見えない空気を図にしたものです。このモデルでは地面は平らで、止まったローターは後流をつくらず、重なった後流は単純な方法で足し合わせています。" + }, + "grid": { + "title": "送電網までの道のり", + "p1": "どの発電機も800ボルト(V)で電気をつくります。コンバーターを通ったあと、風車ごとの変圧器が電圧を上げます。電圧が高いほど同じ電力を少ない電流で送れるので、ケーブルで熱として失われる分が減ります。「ケーブル(C)」をオンにすると、風車から送電網までの電気の流れをたどれます。", + "p2": "36,000ボルト、つまり36キロボルト(kV)の集電ケーブルが、風車と変電所を結んでいます。変電所では別の変圧器が電圧を110kVまで上げ、送電線へ送り出します。これらの電圧は、ヴェスタスが例として示すドイツの発電所の値です。", + "p3": "フル出力なら、風車1基は1時間に4.2メガワット時(MWh)の電気をつくります。でも風はいつも強いわけではないので、1年を平均するとフル出力の約40%になります。この割合を設備利用率といいます。風車1基の発電量は1年で約14,700MWhで、米国の家庭約1,400世帯分をまかなえます。このモデルの架空の一日は年平均より風が強いので、設備利用率は高めに出ます。", + "p4": "風車をつくるのにもエネルギーがかかり、CO2も出ます。一生を通じて、発電する1キロワット時(kWh)あたり約7gのCO2e(すべての温室効果ガスを二酸化炭素に換算した量)を出します。建設に使ったエネルギーは、約8か月で取り戻せます。設計寿命は20年で、それより長く動くことも多いものの、ブレードのリサイクルはまだ難しいのが現状です。風の弱い場所、ふつうの場所、風の強い場所を選んで、ふだんの風がどれほど効いてくるか確かめてみましょう。" + } + } +} From 54c4d4e63bf3a241bc8d9223e30a02f5f723b224 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:05:57 +0200 Subject: [PATCH 029/132] feat(wind-farm): add the German locale --- explainers/wind-farm/locales/de.json | 269 +++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 explainers/wind-farm/locales/de.json diff --git a/explainers/wind-farm/locales/de.json b/explainers/wind-farm/locales/de.json new file mode 100644 index 00000000..5cad0549 --- /dev/null +++ b/explainers/wind-farm/locales/de.json @@ -0,0 +1,269 @@ +{ + "meta": { + "title": "So funktioniert ein Windpark", + "eyebrow": "Erneuerbare Energie", + "tagline": "Siebenundzwanzig Windräder und ein windiger Tag: So macht ein Rotorblatt in Flügelform aus bewegter Luft Strom für das Netz.", + "description": "Ein 3D-Windpark an einem windigen Tag: wie Rotorblätter Wind fangen, was in der Gondel rotiert, warum Windräder Abstand halten, wie Strom ins Netz kommt.", + "summary": "Die Anordnung des Parks, Turm und Rotorblätter, Getriebe und Generator, die Leistungskurve und die Betz-Grenze, der Nachlauf und der Weg ins Netz.", + "socialAlt": "Ein Windpark in hügeliger Landschaft mit einem Windrad ganz nah im Vordergrund, daneben der Titel „So funktioniert ein Windpark“" + }, + "stage": { + "label": "Interaktives Modell eines Windparks", + "gauge": "Live-Werte für Wind und Leistung" + }, + "timeline": { + "label": "Tageszeit", + "phases": "Zu einem Abschnitt des Tages springen", + "speed": "Wiedergabetempo", + "value": "{{time}}", + "during": "{{time}}, {{phase}}", + "speedValue": "{{value}} h/min", + "speedFormat": "{{value}} Stunden des Tages pro Minute", + "phase": { + "night": "Ruhige Nacht", + "morning": "Morgenbrise", + "afternoon": "Volllast", + "storm": "Sturm", + "evening": "Abend" + }, + "jump": { + "night": "Zur ruhigen Nacht springen", + "morning": "Zur Morgenbrise springen", + "afternoon": "Zur Volllast springen", + "storm": "Zum Sturm springen", + "evening": "Zum Abend springen" + } + }, + "controls": { + "label": "Steuerung des Modells", + "siteWind": "Wind am Standort", + "siteWindOptions": { + "calm": "Windarmer Standort", + "typical": "Typischer Standort", + "windy": "Windreicher Standort" + }, + "spacingOptions": { + "5": "5 D", + "7": "7 D", + "9": "9 D" + }, + "nacelleOptions": { + "closed": "Mit Haube", + "open": "Ohne Haube" + }, + "windAtOptions": { + "day": "Tagesverlauf", + "cutIn": "Einschalten, 3 m/s", + "peak": "Beste Ausbeute, 8 m/s", + "rated": "Volllast, 12 m/s", + "rampDown": "Abregeln, 23 m/s", + "cutOut": "Abgeschaltet, 25 m/s" + }, + "groups": { + "siteWind": "Wind am Standort", + "spacing": "Abstand der Reihen", + "nacelle": "Gondelhaube", + "windAt": "Windgeschwindigkeit" + }, + "views": { + "streamlines": { + "name": "Luftströmung", + "short": "Luft", + "hint": "Luftströmung (F)" + }, + "wakes": { + "name": "Nachlauf", + "short": "Nachlauf", + "hint": "Nachlauf (K)" + }, + "cables": { + "name": "Kabel", + "short": "Kabel", + "hint": "Kabel (C)" + }, + "labels": { + "name": "Beschriftungen", + "short": "Namen", + "hint": "Beschriftungen (L)" + } + } + }, + "readouts": { + "wind": "Wind", + "power": "Ein Windrad", + "farm": "Ganzer Park", + "state": { + "idle": "Wartet auf Wind", + "partial": "Teillast", + "full": "Volllast", + "rampDown": "Regelt ab", + "stopping": "Fährt in Fahnenstellung", + "parked": "Geparkt", + "starting": "Fährt an" + } + }, + "units": { + "metresPerSecond": "{{value}} m/s", + "speedPair": "{{ms}} m/s, {{kmh}} km/h", + "rpm": "{{value}} U/min", + "degrees": "{{value}}°", + "mw": "{{value}} MW", + "mwh": "{{value}} MWh", + "percent": "{{value}} %", + "metres": "{{value}} m", + "seconds": "{{value}} s", + "count": "{{value}}", + "hectares": "{{value}} ha", + "squareKm": "{{value}} km²", + "volts": "{{value}} V", + "gramsPerKwh": "{{value}} g CO2e pro kWh", + "months": "{{value}} Monate" + }, + "chapters": { + "farm": { + "count": "Windräder", + "rated": "Volle Leistung des Parks", + "rows": "Zwischen den Reihen, in Windrichtung", + "across": "Zwischen den Windrädern einer Reihe", + "spacingValue": "{{metres}} m, {{diameters}} D", + "built": "Bebaute Fläche", + "project": "Gesamte Projektfläche" + }, + "tower": { + "wind": "Wind auf Nabenhöhe", + "rpm": "Rotordrehzahl", + "turn": "Eine Umdrehung dauert", + "stopped": "Rotor steht", + "tip": "Tempo der Blattspitze", + "height": "Höhe der Blattspitze", + "blade": "Blattlänge", + "swept": "Rotorfläche", + "sweptValue": "{{area}} m², etwa {{pitches}} Fußballfelder" + }, + "nacelle": { + "shaft": "Hauptwelle", + "generator": "Generator", + "gear": "Übersetzung", + "gearValue": "etwa 1 zu {{ratio}}", + "pitch": "Blattwinkel", + "brake": "Scheibenbremse", + "brakeValue": { + "released": "Gelöst", + "holding": "Hält den Rotor" + }, + "volts": "Generatorspannung" + }, + "curve": { + "wind": "Windgeschwindigkeit", + "state": "Zustand der Anlage", + "power": "Leistung der Anlage", + "caption": "Leistung im Wind, Betz-Grenze und Leistung dieser Anlage bei Wind von 0 bis 30 m/s", + "axisWind": "Windgeschwindigkeit (m/s)", + "axisPower": "Leistung (MW)", + "lineWind": "Leistung im Wind", + "lineBetz": "Betz-Grenze", + "lineTurbine": "Diese Anlage", + "windPower": "Leistung im Wind", + "cp": "Genutzter Anteil", + "betz": "Anteil an der Betz-Grenze", + "pitch": "Blattwinkel" + }, + "wakes": { + "spacing": "Abstand der Reihen", + "deficit": "Verlangsamung an der nächsten Reihe", + "loss": "Aktueller Verlust durch Nachlauf", + "top": "Wind oben am Rotor", + "bottom": "Wind unten am Rotor" + }, + "grid": { + "output": "Aktuelle Leistung des Parks", + "energy": "Heute erzeugte Energie", + "homes": "US-Haushalte, die diese Leistung versorgen könnte", + "capacity": "Kapazitätsfaktor dieses Tages", + "voltages": "Spannung auf jeder Stufe", + "voltagesValue": "{{generator}} V, {{collector}} kV, {{grid}} kV", + "annual": "Ein Windrad in einem Jahr", + "annualHomes": "US-Haushalte, die ein Windrad versorgt", + "carbon": "CO2-Fußabdruck", + "payback": "Energetische Amortisation" + } + }, + "parts": { + "land": "Boden", + "foundation": "Fundament", + "tower": "Turm", + "transformer": "Transformator", + "towerCable": "Stromkabel", + "nacelle": "Gondel", + "cooler": "Kühler", + "bedplate": "Maschinenträger", + "hub": "Nabe", + "blades": "Rotorblätter", + "pitchCylinders": "Pitchzylinder", + "mainBearing": "Hauptlager", + "mainShaft": "Hauptwelle", + "gearbox": "Getriebe", + "brakeDisc": "Scheibenbremse", + "generator": "Generator", + "converter": "Umrichter", + "yawDrives": "Azimutantriebe", + "sweptArea": "Rotorfläche", + "streamlinesGroup": "Luftströmung", + "heroWake": "Nachlauf", + "farmLand": "Ackerland", + "farmTurbines": "27 Windräder", + "accessRoads": "Zufahrtswege", + "spacingMarker": "Reihenabstand", + "prevailingWind": "Hauptwindrichtung", + "windArrows": "Wind", + "shearProfile": "Wind je nach Höhe", + "wakePlumes": "Nachläufe", + "collectorCables": "Sammelkabel", + "substation": "Umspannwerk", + "gridLine": "Netzleitung" + }, + "sections": { + "farm": { + "title": "Siebenundzwanzig Windräder und ein windiger Tag", + "p1": "Das ist ein Windpark: 27 Windräder, verteilt über sanft hügelige Felder. Alle sind vom Typ Vestas V150, und jedes leistet bei voller Last 4,2 Megawatt (MW). Ein Megawatt sind eine Million Watt, zusammen schafft der Park also etwa 113 MW. Er ist so groß wie Målarberget in Schweden, ein echter Park mit 27 dieser Anlagen, der seit 2021 läuft.", + "p2": "Die Windräder stehen in Reihen quer zur Hauptwindrichtung, also der Richtung, aus der der Wind am häufigsten weht. Hier kommt er aus Westen. Ingenieure messen die Abstände in Rotordurchmessern, kurz D, und ein D sind hier 150 m. Die Reihen liegen 7 D auseinander, also 1.050 m, die Windräder in einer Reihe 4 D, also 600 m. Jede Reihe ist seitlich versetzt, damit kein Windrad direkt hinter einem aus der Reihe davor steht.", + "p3": "Von oben wirkt der Park dicht gedrängt, doch der größte Teil der Fläche ist weiter Ackerland. Nur etwa 0,3 Hektar pro Megawatt sind wirklich bebaut, und das sind vor allem Zufahrtswege, nicht die Sockel der Windräder. Ein Hektar ist ein Quadrat mit 100 m Seitenlänge. Der Strom fließt durch Sammelkabel zu einem Umspannwerk und von dort weiter ins Netz.", + "p4": "Wie Målarberget wirklich angeordnet ist, wurde nicht veröffentlicht, deshalb zeigt das Modell eine eigene Anordnung. Außerdem spielt es in einer Schleife einen erfundenen Windtag ab. Das ist kein Durchschnittstag: Er packt eine ruhige Nacht, einen windigen Morgen, einen Nachmittag mit voller Leistung und einen Sturm am Abend in 24 Stunden. Die Uhr läuft Hunderte Male schneller als in Wirklichkeit, aber jeder Rotor dreht sich in seinem echten Tempo. Wege, Kabel und Windpfeile sind breiter gezeichnet, als sie sind, damit Sie sie auch aus Kilometern Entfernung sehen, und das Licht ändert sich nicht mit der Uhrzeit." + }, + "tower": { + "title": "Blätter, die im Kreis fliegen", + "p1": "Aus der Nähe ist ein einzelnes Windrad riesig. Der Turm aus Stahl hebt die Nabe 105 m über den Boden, und der Rotor, also die Nabe mit ihren drei Blättern, misst 150 m im Durchmesser. Steht ein Blatt ganz oben, reicht seine Spitze etwa 180 m hoch, fast doppelt so hoch wie Big Ben. Dort oben weht der Wind schneller als in Bodennähe, wie Kapitel 5 zeigt.", + "p2": "Jedes Rotorblatt ist etwa 74 m lang, fast so lang wie die Spannweite eines Airbus A380, des größten Passagierflugzeugs. Es besteht aus Kohle- und Glasfasern in Epoxidharz, einem harten Kunststoffkleber. Ein Blatt ist kein Segel, das der Wind vor sich herschiebt. Die Luft strömt darüber wie über den Flügel eines Flugzeugs, und die gewölbte Form erzeugt Auftrieb, eine Kraft quer zum Wind. Der Auftrieb zieht jedes Blatt herum, und der Rotor dreht sich.", + "p3": "Bei voller Drehzahl schafft der Rotor nur etwa 10 Umdrehungen pro Minute, kurz 10 U/min. Das ist eine Umdrehung alle 6 Sekunden und sieht gemächlich aus. Doch die Spitzen ziehen einen riesigen Kreis und sind darum etwa 80 m/s schnell, knapp 300 km/h. Schieben Sie den Windregler, dann dreht der Rotor mit dem Wind schneller, oder wählen Sie „Tagesverlauf“, dann gibt wieder die Uhr den Wind vor. In diesem Modell sind die Spitzen 8-mal so schnell wie der Wind, bis der Rotor seine Höchstdrehzahl erreicht.", + "p4": "Der Kreis, den die Blätter überstreichen, ist die Rotorfläche: etwa 17.700 m², so groß wie rund 2,5 Fußballfelder. Nur der Wind, der durch diesen Kreis strömt, kann den Rotor drehen. Die Strömungslinien rund um den Rotor sind nur gezeichnet, denn echte Luft ist unsichtbar. Der Transformator steht in diesem Modell am Fuß des Turms, weil nicht veröffentlicht ist, wo die V150 ihn unterbringt. Aus demselben Grund fehlt die leichte Neigung des Rotors." + }, + "nacelle": { + "title": "Ein Blick in die Gondel", + "p1": "Der lange Kasten hinter der Nabe ist die Gondel, 12,8 m lang. Wählen Sie „Ohne Haube“, um hineinzusehen. Darin stecken die Maschinen, die aus der langsamen, kraftvollen Drehung des Rotors Strom machen. Sie stehen alle auf dem Maschinenträger, einem schweren Rahmen aus Gusseisen, und ein Kühler auf dem Dach führt ihre Wärme ab.", + "p2": "In der Nabe sitzt jedes Blatt auf einem Lager, damit es sich drehen lässt. Drei Pitchzylinder, einer pro Blatt, verstellen die Blätter mit Öldruck. Den Winkel des Blattes zum Wind nennt man Pitch oder Blattwinkel, und er bestimmt, wie viel der Wind zu fassen bekommt. Hinter der Nabe dreht sich die Hauptwelle in ihrem Hauptlager, genauso langsam wie der Rotor, mit etwa 10 U/min.", + "p3": "Ein Generator muss sich schnell drehen, deshalb sitzt dazwischen ein Getriebe. Es hat zwei Planetenstufen, in denen kleine Zahnräder um ein mittleres kreisen, und eine Stirnradstufe mit schrägen Zähnen. Zusammen machen sie die Welle etwa 140-mal schneller, bis auf etwa 1.485 U/min. Der Generator ist ein Asynchrongenerator mit Kupferwicklungen und ohne Magnete. Seine gesamte Leistung fließt mit 800 Volt durch den Umrichter, der sie passend fürs Netz umformt.", + "p4": "Auf der Welle sitzt eine Scheibenbremse. Doch nicht sie stoppt das Windrad im Sturm, sondern die Blätter: Sie drehen ihre Kante in den Wind. Die Bremse hält den Rotor erst fest, wenn er schon langsam ist, und parkt ihn so. Oben auf dem Turm drehen Azimutantriebe die ganze Gondel in den Wind. In diesem Modell folgt sie dem Wind exakt, weil nicht veröffentlicht ist, wie schnell die echte V150 ihre Gondel dreht." + }, + "curve": { + "title": "Wie viel Wind ein Windrad einfangen kann", + "p1": "Unter 3 m/s, der Einschaltgeschwindigkeit, wartet der Rotor. Darüber steigt die Leistung schnell, denn die Leistung im Wind wächst mit der dritten Potenz der Geschwindigkeit: Geschwindigkeit mal Geschwindigkeit mal Geschwindigkeit. Weht der Wind doppelt so schnell, trägt er achtmal so viel Leistung. Deshalb liefert das Windrad bei 4 m/s etwa 0,3 MW, bei 8 m/s etwa 2,5 MW und ab etwa 12 m/s seine vollen 4,2 MW. Schieben Sie den Windregler und sehen Sie zu, wie der Punkt die Kurve entlangwandert.", + "p2": "Kein Windrad kann die ganze Leistung einfangen. Um Energie zu entnehmen, muss der Rotor die Luft bremsen, doch hinter ihm muss sie noch abfließen können. Der Physiker Albert Betz zeigte, dass selbst ein perfekter Rotor höchstens 59,3 % der Leistung nutzen kann. Das ist die Betz-Grenze. Dieses Windrad schafft im besten Fall knapp die Hälfte, bei etwa 7 bis 8 m/s, und das Diagramm zeigt, wie nah es der Grenze kommt.", + "p3": "Ist die volle Leistung erreicht, bringt der Wind mehr Leistung, als der Generator aufnehmen kann. Deshalb drehen die Pitchzylinder die Blätter ein Stück aus dem Wind, und der größte Teil des Windes strömt vorbei. Bei 15 m/s nutzt das Windrad nur etwa 12 % der Leistung im Wind. Die Blattwinkel oberhalb der vollen Leistung hat das Modell selbst festgelegt, weil die der V150 nicht veröffentlicht sind. Über 20 m/s beginnt die Anlage, ihre Leistung zurückzufahren.", + "p4": "Bei der Abschaltgeschwindigkeit, etwa 22 bis 25 m/s, drehen sich die Blätter mit der Kante in den Wind. Das nennt man Fahnenstellung: Der Rotor wird von selbst langsamer, dann parkt ihn die Scheibenbremse. Das Windrad startet erst wieder, wenn der Wind um etwa 2 m/s nachgelassen hat. In diesem Modell stoppen alle 27 Windräder gemeinsam bei 24,5 m/s und starten bei 22,5 m/s neu. Stopp und Neustart dauern auf der Uhr jeweils 20 Minuten, länger als in Wirklichkeit, damit Sie zusehen können." + }, + "wakes": { + "title": "Warum Windräder so viel Abstand halten", + "p1": "Ein Windrad nimmt dem Wind Energie, deshalb ist die Luft dahinter langsamer. Diese Spur aus langsamer, verwirbelter Luft heißt Nachlauf. Schalten Sie Nachlauf (K) ein, dann sehen Sie hinter jedem drehenden Rotor einen Schweif. Am stärksten ist der Nachlauf bei mittlerem Wind, wenn der Rotor die Luft am kräftigsten bremst. Oberhalb der vollen Leistung lassen die Blätter mehr Wind vorbei, und der Nachlauf wird schwächer.", + "p2": "Das Modell zeichnet den Nachlauf nach einem einfachen, weit verbreiteten Rezept, dem Jensen-Modell. Es behandelt jeden Nachlauf als Kegel, der breiter wird und sich langsam wieder mit schnellerer Luft füllt. Wie schnell er breiter wird, legt eine einzige Zahl fest, hier 0,075, der übliche Wert an Land. Bei 8 m/s trifft ein Windrad 7 D weiter windabwärts noch auf Luft, die etwa 13 % langsamer ist. Weil die Leistung mit der dritten Potenz des Windes wächst, kostet das dieses Windrad etwa ein Drittel seiner Leistung.", + "p3": "Übers Jahr kostet der Nachlauf einen Windpark etwa 5 bis 15 % seiner Energie. Ein Beispielpark von Vestas mit 24 dieser Windräder verliert etwa 6 %. Für eine einzelne Windrichtung kann der Verlust sogar über 30 % liegen. Deshalb stehen Windräder in Windrichtung meist 5 bis 9 D auseinander und quer dazu 3 bis 5 D. Probieren Sie 5, 7 und 9 D aus: Mit größeren Abständen geht weniger Wind verloren, aber der Park braucht mehr Fläche.", + "p4": "Außerdem wird der Wind mit der Höhe stärker, weil der Boden die Luft in seiner Nähe bremst. Schalten Sie Luftströmung (F) ein, um das Profil zu sehen: Oben trifft der Rotor auf schnelleren Wind als unten. Die Schweife und Pfeile zeichnen Luft, die man in Wirklichkeit nicht sieht. In diesem Modell ist der Boden flach, ein stehender Rotor hinterlässt keinen Nachlauf, und wo sich Nachläufe überlappen, addieren sie sich auf einfache Weise." + }, + "grid": { + "title": "Der Weg ins Netz", + "p1": "Jeder Generator erzeugt Strom mit 800 Volt (V). Nach dem Umrichter erhöht der eigene Transformator des Windrads die Spannung. Höhere Spannung überträgt dieselbe Leistung mit weniger Strom, deshalb geht in den Kabeln weniger als Wärme verloren. Schalten Sie Kabel (C) ein, um den Strom von den Windrädern bis ins Netz zu verfolgen.", + "p2": "Sammelkabel mit 36.000 Volt, also 36 Kilovolt (kV), verbinden die Windräder mit dem Umspannwerk. Dort hebt ein weiterer Transformator die Spannung auf 110 kV für die Netzleitung. Diese Spannungen stammen aus einem Beispielpark von Vestas in Deutschland.", + "p3": "Bei voller Leistung erzeugt ein Windrad jede Stunde 4,2 Megawattstunden (MWh). Doch der Wind weht nicht immer kräftig, deshalb kommt ein Windrad übers Jahr im Schnitt auf etwa 40 % seiner vollen Leistung. Diesen Anteil nennt man Kapazitätsfaktor. In einem Jahr erzeugt ein Windrad etwa 14.700 MWh, genug für etwa 1.400 Haushalte in den USA. Der erfundene Tag des Modells ist windiger als der Jahresdurchschnitt, deshalb fällt sein Kapazitätsfaktor höher aus.", + "p4": "Auch der Bau eines Windrads kostet Energie und setzt CO2 frei. Über seine Lebensdauer kommen so auf jede erzeugte Kilowattstunde (kWh) etwa 7 g CO2e, wobei CO2e jedes Treibhausgas so zählt, als wäre es Kohlendioxid. Die Energie für seinen Bau hat es nach etwa 8 Monaten wieder eingespielt. Ausgelegt ist es auf 20 Jahre, oft läuft es länger, doch seine Blätter lassen sich noch immer schwer recyceln. Wählen Sie einen windarmen, typischen oder windreichen Standort, um zu sehen, wie sehr es auf den üblichen Wind vor Ort ankommt." + } + } +} From a7de202ace9f582545629a5b746377b5db1ff8d0 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:19:21 +0200 Subject: [PATCH 030/132] fix(wind-farm): make the English copy easier to follow --- explainers/wind-farm/chapters.html | 196 ++++++++++++++------------- explainers/wind-farm/locales/en.json | 54 ++++---- 2 files changed, 128 insertions(+), 122 deletions(-) diff --git a/explainers/wind-farm/chapters.html b/explainers/wind-farm/chapters.html index 87326d79..daf9c47c 100644 --- a/explainers/wind-farm/chapters.html +++ b/explainers/wind-farm/chapters.html @@ -4,29 +4,30 @@

Twenty-seven turbines and on

This is a wind farm: 27 turbines spread over rolling fields. Each one is a Vestas V150, and at full power it makes 4.2 megawatts (MW). A megawatt is a - million watts, so together the farm can make about 113 MW. It is sized like Målarberget in - Sweden, a real farm of 27 of these turbines that has run since 2021. + million watts, so the whole farm can make about 113 MW. That matches Målarberget in Sweden, a + real farm of 27 of these turbines that has run since 2021.

The turbines stand in rows across the prevailing wind, the - direction the wind most often blows from. Here that is the west. Engineers measure the gaps in - rotor diameters, written D, and one D here is 150 m. Rows sit 7 D apart, or 1,050 m, and - turbines in a row sit 4 D apart, or 600 m. Each row is shifted sideways, so no turbine stands - right behind one in the row in front. + direction the wind blows from most often. Here that is the west. Engineers measure the gaps in + rotor widths, written D. One D is the width of a rotor from tip to tip, 150 m here. The rows are + 7 D apart, or 1,050 m. Within a row, turbines are 4 D apart, or 600 m. Each row is shifted a + little sideways, so no turbine hides right behind another.

From above it looks crowded, but most of the land is still fields. Only about 0.3 hectares per - megawatt is actually built on, and most of that is access roads, not turbine - bases. A hectare is a square 100 m on each side. The power runs through - collector cables to a substation, where it heads for the grid. + megawatt is actually built on. A hectare is a square 100 m on each side. Most of that built land + is access roads, not turbine bases. Collector cables gather + the power from every turbine and carry it to a substation, where it joins the + grid.

- Målarberget's real layout is not published, so the one you see is this model's own. The model - also plays a made-up day of wind, on a loop. It is not an average day: it fits a calm night, a + The real layout of Målarberget is not published, so this one is the model's own. The day of wind + is made up too, and it plays on a loop. It is not an average day: it packs a calm night, a breezy morning, a full-power afternoon and an evening storm into 24 hours. The clock runs - hundreds of times faster than real time, but every rotor turns at its real speed. Roads, cables - and wind arrows are drawn wider than real so you can see them from kilometres away, and the - light does not follow the clock. + hundreds of times faster than real time, yet every rotor turns at its real speed. Roads, cables + and wind arrows are drawn wider than they are, so you can see them from kilometres away. The + light stays the same at every hour.

@@ -60,31 +61,32 @@

Twenty-seven turbines and on

02

Blades that fly in circles

- Up close, one turbine is huge. The steel tower lifts the hub 105 m above the - ground, and the rotor, the hub with its three blades, is 150 m across. At the - top of its turn, a blade tip reaches about 180 m, almost twice as high as Big Ben. Up there the - wind blows faster than near the ground, as chapter 5 shows. + Up close, one turbine is huge. The rotor is three blades fixed to a round hub, + and it is 150 m across. The steel tower lifts the hub 105 m above the ground. + At the top of its turn, a blade tip reaches about 180 m, almost twice the height of Big Ben. Up + there the wind is faster than near the ground, as chapter 5 shows.

Each blade is about 74 m long, almost the wingspan of an Airbus A380, the - biggest airliner. It is made of carbon fibre and glass fibre set in epoxy, a hard plastic glue. - A blade is not a sail that the wind pushes. The wind flows over it as over a plane's wing, and - the curved shape creates lift, a force across the wind. Lift pulls each blade - round, and the rotor turns. + biggest airliner. It is made of carbon and glass fibre set in epoxy, a hard plastic glue. The + wind does not push a blade like a sail. It flows over the blade the way it flows over a plane's + wing. The curved shape creates lift, a force at right angles to the wind. Lift + pulls each blade round, and the rotor turns.

- At full speed the rotor makes only about 10 turns a minute, or 10 rpm (revolutions per minute). - That is one turn every 6 seconds, which looks lazy. But the tips travel a huge circle, so they - move at about 80 m/s, close to 300 km/h. Drag the wind slider to see the rotor speed up with the - wind, or pick Follow the day to give the wind back to the clock. In this model the tips move 8 - times faster than the wind until the rotor reaches its top speed. + At full speed the rotor turns only about 10 times a minute, or 10 rpm (revolutions per minute). + That is one turn every 6 seconds, which looks lazy. But the tips travel round a huge circle, so + they move at about 80 m/s, close to 300 km/h. Drag the wind slider and watch the rotor speed up + with the wind. Pick Follow the day to hand the wind back to the clock. In this model the tips + move 8 times as fast as the wind, until the rotor reaches its top speed.

- The circle the blades sweep is the swept area: about 17,700 m², the size of - about 2.5 football pitches. Only the wind that passes through this circle can turn the rotor. - The lines of air round the rotor are a drawing, since real air is invisible. The - transformer stands at the foot of the tower in this model, because where the - V150 keeps it is not published. For the same reason, the slight tilt of the rotor is not drawn. + The circle the blades sweep is the swept area: about 17,700 m², or about 2.5 + football pitches. Only wind that passes through this circle can turn the rotor. The lines of air + round the rotor are a drawing, since real air is invisible. The turbine's + transformer raises the voltage of its power for the cables. Where the V150 + keeps it is not published, so this model puts it at the foot of the tower. For the same reason, + the rotor's slight tilt is not drawn.

@@ -147,32 +149,32 @@

Blades that fly in circles

03

Inside the nacelle

- The long box behind the hub is the nacelle, 12.8 m long. Pick Cover off to look + The box behind the hub is the nacelle, 12.8 m long. Pick Cover off to look inside. It holds the machines that turn the rotor's slow, strong spin into electricity. They all - stand on the bedplate, a heavy cast iron frame, and a - cooler on the roof carries their heat away. + stand on the bedplate, a heavy cast iron frame. A cooler on + the roof carries their heat away.

- In the hub, each blade sits on a bearing so it can twist. Three - pitch cylinders, one per blade, twist them with oil pressure. The blade's angle - to the wind is its pitch, and it sets how much the wind can grab. Behind the + The angle of a blade to the wind is its pitch. It sets how much of the wind the + blade can grab. Each blade sits in the hub on a bearing, so it can twist. Three + pitch cylinders, one per blade, twist the blades with oil pressure. Behind the hub, the main shaft turns in its main bearing at the rotor's - own slow speed, about 10 rpm. + slow speed, about 10 rpm.

- A generator needs to spin fast, so a gearbox sits in between. It has two - planetary stages, where small gears circle round a central one, and a helical stage with slanted - teeth. Together they make the shaft about 140 times faster, up to about 1,485 rpm. The - generator is an induction generator, with copper windings and no magnets. All - its power, at 800 volts, passes through the converter, which reshapes it to - match the grid. + The generator needs a fast spin, so a gearbox sits in between. It has two + planetary stages, where small gears circle round a central one, and one helical stage with + slanted teeth. Together they spin the output about 140 times faster, up to about 1,485 rpm. The + generator is an induction generator: copper windings, no magnets. Its power + comes out at 800 volts and all of it passes through the converter. The + converter reshapes the power so it keeps in step with the grid.

On the shaft sits a disc brake. It is not what stops the turbine in a storm: - the blades do that by turning edge-on to the wind. The brake only holds the rotor once it is - slow, to park it. On top of the tower, yaw drives turn the whole nacelle to - face the wind. In this model it follows the wind exactly, because the V150's real turning speed - is not published. + the blades do that by turning edge-on to the wind. The brake only holds the rotor still once it + has slowed down, to park it. On top of the tower, yaw drives turn the whole + nacelle to face the wind. This turning is called yaw. In this model the nacelle follows the wind + exactly, because the V150's real turning speed is not published.

04

How much wind a turbine can catch

- Below 3 m/s, the cut-in speed, the rotor waits. Above it, power climbs fast, - because the power in the wind grows with the speed cubed: speed times speed times speed. Double - the wind, and it carries eight times the power. So the turbine makes about 0.3 MW at 4 m/s, - about 2.5 MW at 8 m/s and its full 4.2 MW from about 12 m/s. Drag the wind slider and watch the - dot move along the curve. + The rotor waits until the wind reaches 3 m/s, the cut-in speed. Then power + climbs fast. The power in the wind grows with the speed cubed: speed times speed times speed. + Double the wind, and it carries eight times the power. So the turbine makes about 0.3 MW at 4 + m/s, about 2.5 MW at 8 m/s and its full 4.2 MW from about 12 m/s. Drag the wind slider and watch + the dot move along the curve.

- No turbine can catch all of it. To take energy, the rotor has to slow the air down, yet the air - must still flow out behind it. The physicist Albert Betz showed that even a perfect rotor can - take at most 59.3 % of the power. That is the Betz limit. This turbine's best - is just under half, at about 7 to 8 m/s, and the chart shows how close it comes. + No turbine can catch all of that power. To take energy, the rotor must slow the air down, but + the air still has to flow away behind it. If the rotor stopped the air completely, no new air + could get through. The physicist Albert Betz showed that even a perfect rotor can take at most + 59.3 % of the wind's power. That is the Betz limit. This turbine's best is just + under half, at about 7 to 8 m/s. The chart shows how close it comes.

Above full power the wind brings more than the generator can take. So the pitch cylinders turn - the blades a little out of the wind, and most of it slips past. At 15 m/s the turbine keeps only - about 12 % of the power in the wind. The pitch angles shown above full power are this model's - own, since the V150's are not published. Above 20 m/s, power starts to ramp down. + the blades a little out of the wind, and most of the wind slips past. At 15 m/s the turbine + keeps only about 12 % of the power in the wind. The pitch angles this model shows above full + power are its own, since the V150's are not published. Above 20 m/s, the turbine starts to ramp + its power down.

- At the cut-out speed, about 22 to 25 m/s, the blades turn edge-on to the wind. - This is feathering: the rotor slows on its own, and then the disc brake parks - it. The turbine starts again only once the wind has dropped by about 2 m/s. In this model all 27 - turbines stop together at 24.5 m/s and restart at 22.5 m/s. The stop and the restart each take - 20 minutes of the clock, longer than real, so you can watch them. + At the cut-out speed, about 22 to 25 m/s, the wind is too strong to work in. + The blades turn edge-on to the wind. This is feathering: the rotor slows down + on its own, and then the disc brake parks it. The turbine starts again only once the wind has + dropped by about 2 m/s. In this model all 27 turbines stop together at 24.5 m/s and restart at + 22.5 m/s. Stopping and restarting each take 20 minutes on the clock, longer than in real life, + so you can watch them.

@@ -355,28 +360,29 @@

How much wind a turbine ca

Why turbines stand so far apart

A turbine takes energy out of the wind, so the air behind it moves slower. This trail of slow, - stirred air is called a wake. Switch on Wakes (K) to see a plume behind every - turning rotor. Wakes are strongest in moderate wind, when the rotor holds back the air the - hardest. Above full power the blades let more wind slip past, and the wakes fade. + stirred-up air is called a wake, like the trail behind a boat. Switch on Wakes + (K) to see a plume behind every turning rotor. Wakes are strongest in moderate wind, when the + rotor holds back the air the most. Above full power the blades let more wind slip past, and the + wakes fade.

This model draws wakes with a simple, widely used recipe, the Jensen model. It - treats each wake as a cone that widens and slowly fills back up with faster air. How fast it - widens is set by one number, 0.075 here, the usual value for land. At 8 m/s, a turbine 7 D - downwind still meets air about 13 % slower. Because power follows the wind cubed, that costs it - about a third of its power. + treats each wake as a cone that widens and slowly fills back up with faster air. One number sets + how fast it widens: 0.075 here, the usual value on land. At 8 m/s, a turbine 7 D downwind still + meets air about 13 % slower. Power follows the speed cubed, so that costs it about a third of + its power.

- Over a year, wakes cost a farm about 5 to 15 % of its energy. A Vestas example farm of 24 of - these turbines loses about 6 %. For a single wind direction, the loss can pass 30 %. That is why - rows usually stand 5 to 9 D apart along the wind and 3 to 5 D apart across it. Try 5, 7 and 9 D: - wider gaps lose less wind but need more land. + Over a year, wakes cost a farm about 5 to 15 % of its energy. In a Vestas example farm of 24 of + these turbines, the loss is about 6 %. When the wind comes from one unlucky direction, the loss + can pass 30 %. That is why rows usually stand 5 to 9 D apart along the wind, and turbines 3 to 5 + D apart across it. Try 5, 7 and 9 D: wider gaps lose less to wakes but need more land.

- Wind also grows with height, because the ground drags on the air near it. Switch on Airflow (F) - to see the profile: the top of the rotor meets faster wind than the bottom. The plumes and - arrows are drawings of invisible air. In this model the ground is flat, a stopped rotor leaves - no wake, and wakes that overlap add up in a simple way. + Wind also gets stronger with height, because the ground drags on the air near it. Switch on + Airflow (F) to see this: the top of the rotor meets faster wind than the bottom. The plumes and + arrows are only drawings. In this model the ground is flat, a stopped rotor leaves no wake, and + overlapping wakes add up in a simple way.

-
Farm loss to wakes now
+
Farm power lost to wakes now
4.9 %
@@ -436,28 +442,28 @@

Why turbines stand so far

The road to the grid

Each generator makes power at 800 volts (V). After the converter, the turbine's own - transformer raises the voltage. Higher voltage carries the same power with less - current, so less of it is lost as heat in the cables. Switch on Cables (C) to follow the power - from the turbines to the grid. + transformer raises the voltage. At a higher voltage the same power needs less + current, so the cables waste less of it as heat. Switch on Cables (C) to follow the power from + the turbines to the grid.

Collector cables at 36,000 volts, or 36 kilovolts (kV), link the turbines to - the substation. There another transformer raises the voltage to 110 kV for the - grid line. These voltages come from a Vestas example farm in Germany. + the substation. There another transformer raises the voltage again, to 110 kV, + for the grid line. These voltages come from a Vestas example farm in Germany.

At full power, one turbine makes 4.2 megawatt hours (MWh) every hour. But the wind is not always - strong, so over a year a turbine averages about 40 % of its full power. That share is the + strong. Over a year, a turbine averages about 40 % of its full power. That share is its capacity factor. In a year, one turbine makes about 14,700 MWh, enough for - about 1,400 US homes. The model's made-up day is windier than a year's average, so its capacity + about 1,400 US homes. The model's made-up day is windier than an average day, so its capacity factor comes out higher.

- Making a turbine costs energy and carbon too. Over its life, it adds about 7 g of CO2e for each - kilowatt hour (kWh) it makes, where CO2e counts every greenhouse gas as if it were carbon - dioxide. It pays back the energy used to build it in about 8 months. It is designed for 20 years - and often runs longer, though its blades are still hard to recycle. Pick a calm, typical or - windy site to see how much the everyday wind matters. + Building a turbine costs energy and carbon too. Over its whole life, it causes about 7 g of CO2e + for each kilowatt hour (kWh) it makes. CO2e counts every greenhouse gas as if it were carbon + dioxide. A turbine pays back the energy used to build it in about 8 months. It is designed for + 20 years and often runs longer, though its blades are still hard to recycle. Pick a calm, + typical or windy site to see how much the everyday wind matters.

-
Capacity factor of this day
+
This day's capacity factor
43 %
diff --git a/explainers/wind-farm/locales/en.json b/explainers/wind-farm/locales/en.json index 0d0fba59..1d8653fe 100644 --- a/explainers/wind-farm/locales/en.json +++ b/explainers/wind-farm/locales/en.json @@ -94,7 +94,7 @@ "farm": "Whole farm", "state": { "idle": "Waiting for wind", - "partial": "Partial load", + "partial": "Below full power", "full": "Full power", "rampDown": "Ramping down", "stopping": "Feathering to stop", @@ -171,7 +171,7 @@ "wakes": { "spacing": "Gap between rows", "deficit": "Wake slowdown at the next row", - "loss": "Farm loss to wakes now", + "loss": "Farm power lost to wakes now", "top": "Wind at the top of the rotor", "bottom": "Wind at the bottom of the rotor" }, @@ -179,7 +179,7 @@ "output": "Farm output now", "energy": "Energy made today", "homes": "US homes this output could run", - "capacity": "Capacity factor of this day", + "capacity": "This day's capacity factor", "voltages": "Voltage at each step", "voltagesValue": "{{generator}} V, {{collector}} kV, {{grid}} kV", "annual": "One turbine in a year", @@ -225,45 +225,45 @@ "sections": { "farm": { "title": "Twenty-seven turbines and one windy day", - "p1": "This is a wind farm: 27 turbines spread over rolling fields. Each one is a Vestas V150, and at full power it makes 4.2 megawatts (MW). A megawatt is a million watts, so together the farm can make about 113 MW. It is sized like Målarberget in Sweden, a real farm of 27 of these turbines that has run since 2021.", - "p2": "The turbines stand in rows across the prevailing wind, the direction the wind most often blows from. Here that is the west. Engineers measure the gaps in rotor diameters, written D, and one D here is 150 m. Rows sit 7 D apart, or 1,050 m, and turbines in a row sit 4 D apart, or 600 m. Each row is shifted sideways, so no turbine stands right behind one in the row in front.", - "p3": "From above it looks crowded, but most of the land is still fields. Only about 0.3 hectares per megawatt is actually built on, and most of that is access roads, not turbine bases. A hectare is a square 100 m on each side. The power runs through collector cables to a substation, where it heads for the grid.", - "p4": "Målarberget's real layout is not published, so the one you see is this model's own. The model also plays a made-up day of wind, on a loop. It is not an average day: it fits a calm night, a breezy morning, a full-power afternoon and an evening storm into 24 hours. The clock runs hundreds of times faster than real time, but every rotor turns at its real speed. Roads, cables and wind arrows are drawn wider than real so you can see them from kilometres away, and the light does not follow the clock." + "p1": "This is a wind farm: 27 turbines spread over rolling fields. Each one is a Vestas V150, and at full power it makes 4.2 megawatts (MW). A megawatt is a million watts, so the whole farm can make about 113 MW. That matches Målarberget in Sweden, a real farm of 27 of these turbines that has run since 2021.", + "p2": "The turbines stand in rows across the prevailing wind, the direction the wind blows from most often. Here that is the west. Engineers measure the gaps in rotor widths, written D. One D is the width of a rotor from tip to tip, 150 m here. The rows are 7 D apart, or 1,050 m. Within a row, turbines are 4 D apart, or 600 m. Each row is shifted a little sideways, so no turbine hides right behind another.", + "p3": "From above it looks crowded, but most of the land is still fields. Only about 0.3 hectares per megawatt is actually built on. A hectare is a square 100 m on each side. Most of that built land is access roads, not turbine bases. Collector cables gather the power from every turbine and carry it to a substation, where it joins the grid.", + "p4": "The real layout of Målarberget is not published, so this one is the model's own. The day of wind is made up too, and it plays on a loop. It is not an average day: it packs a calm night, a breezy morning, a full-power afternoon and an evening storm into 24 hours. The clock runs hundreds of times faster than real time, yet every rotor turns at its real speed. Roads, cables and wind arrows are drawn wider than they are, so you can see them from kilometres away. The light stays the same at every hour." }, "tower": { "title": "Blades that fly in circles", - "p1": "Up close, one turbine is huge. The steel tower lifts the hub 105 m above the ground, and the rotor, the hub with its three blades, is 150 m across. At the top of its turn, a blade tip reaches about 180 m, almost twice as high as Big Ben. Up there the wind blows faster than near the ground, as chapter 5 shows.", - "p2": "Each blade is about 74 m long, almost the wingspan of an Airbus A380, the biggest airliner. It is made of carbon fibre and glass fibre set in epoxy, a hard plastic glue. A blade is not a sail that the wind pushes. The wind flows over it as over a plane's wing, and the curved shape creates lift, a force across the wind. Lift pulls each blade round, and the rotor turns.", - "p3": "At full speed the rotor makes only about 10 turns a minute, or 10 rpm (revolutions per minute). That is one turn every 6 seconds, which looks lazy. But the tips travel a huge circle, so they move at about 80 m/s, close to 300 km/h. Drag the wind slider to see the rotor speed up with the wind, or pick Follow the day to give the wind back to the clock. In this model the tips move 8 times faster than the wind until the rotor reaches its top speed.", - "p4": "The circle the blades sweep is the swept area: about 17,700 m², the size of about 2.5 football pitches. Only the wind that passes through this circle can turn the rotor. The lines of air round the rotor are a drawing, since real air is invisible. The transformer stands at the foot of the tower in this model, because where the V150 keeps it is not published. For the same reason, the slight tilt of the rotor is not drawn." + "p1": "Up close, one turbine is huge. The rotor is three blades fixed to a round hub, and it is 150 m across. The steel tower lifts the hub 105 m above the ground. At the top of its turn, a blade tip reaches about 180 m, almost twice the height of Big Ben. Up there the wind is faster than near the ground, as chapter 5 shows.", + "p2": "Each blade is about 74 m long, almost the wingspan of an Airbus A380, the biggest airliner. It is made of carbon and glass fibre set in epoxy, a hard plastic glue. The wind does not push a blade like a sail. It flows over the blade the way it flows over a plane's wing. The curved shape creates lift, a force at right angles to the wind. Lift pulls each blade round, and the rotor turns.", + "p3": "At full speed the rotor turns only about 10 times a minute, or 10 rpm (revolutions per minute). That is one turn every 6 seconds, which looks lazy. But the tips travel round a huge circle, so they move at about 80 m/s, close to 300 km/h. Drag the wind slider and watch the rotor speed up with the wind. Pick Follow the day to hand the wind back to the clock. In this model the tips move 8 times as fast as the wind, until the rotor reaches its top speed.", + "p4": "The circle the blades sweep is the swept area: about 17,700 m², or about 2.5 football pitches. Only wind that passes through this circle can turn the rotor. The lines of air round the rotor are a drawing, since real air is invisible. The turbine's transformer raises the voltage of its power for the cables. Where the V150 keeps it is not published, so this model puts it at the foot of the tower. For the same reason, the rotor's slight tilt is not drawn." }, "nacelle": { "title": "Inside the nacelle", - "p1": "The long box behind the hub is the nacelle, 12.8 m long. Pick Cover off to look inside. It holds the machines that turn the rotor's slow, strong spin into electricity. They all stand on the bedplate, a heavy cast iron frame, and a cooler on the roof carries their heat away.", - "p2": "In the hub, each blade sits on a bearing so it can twist. Three pitch cylinders, one per blade, twist them with oil pressure. The blade's angle to the wind is its pitch, and it sets how much the wind can grab. Behind the hub, the main shaft turns in its main bearing at the rotor's own slow speed, about 10 rpm.", - "p3": "A generator needs to spin fast, so a gearbox sits in between. It has two planetary stages, where small gears circle round a central one, and a helical stage with slanted teeth. Together they make the shaft about 140 times faster, up to about 1,485 rpm. The generator is an induction generator, with copper windings and no magnets. All its power, at 800 volts, passes through the converter, which reshapes it to match the grid.", - "p4": "On the shaft sits a disc brake. It is not what stops the turbine in a storm: the blades do that by turning edge-on to the wind. The brake only holds the rotor once it is slow, to park it. On top of the tower, yaw drives turn the whole nacelle to face the wind. In this model it follows the wind exactly, because the V150's real turning speed is not published." + "p1": "The box behind the hub is the nacelle, 12.8 m long. Pick Cover off to look inside. It holds the machines that turn the rotor's slow, strong spin into electricity. They all stand on the bedplate, a heavy cast iron frame. A cooler on the roof carries their heat away.", + "p2": "The angle of a blade to the wind is its pitch. It sets how much of the wind the blade can grab. Each blade sits in the hub on a bearing, so it can twist. Three pitch cylinders, one per blade, twist the blades with oil pressure. Behind the hub, the main shaft turns in its main bearing at the rotor's slow speed, about 10 rpm.", + "p3": "The generator needs a fast spin, so a gearbox sits in between. It has two planetary stages, where small gears circle round a central one, and one helical stage with slanted teeth. Together they spin the output about 140 times faster, up to about 1,485 rpm. The generator is an induction generator: copper windings, no magnets. Its power comes out at 800 volts and all of it passes through the converter. The converter reshapes the power so it keeps in step with the grid.", + "p4": "On the shaft sits a disc brake. It is not what stops the turbine in a storm: the blades do that by turning edge-on to the wind. The brake only holds the rotor still once it has slowed down, to park it. On top of the tower, yaw drives turn the whole nacelle to face the wind. This turning is called yaw. In this model the nacelle follows the wind exactly, because the V150's real turning speed is not published." }, "curve": { "title": "How much wind a turbine can catch", - "p1": "Below 3 m/s, the cut-in speed, the rotor waits. Above it, power climbs fast, because the power in the wind grows with the speed cubed: speed times speed times speed. Double the wind, and it carries eight times the power. So the turbine makes about 0.3 MW at 4 m/s, about 2.5 MW at 8 m/s and its full 4.2 MW from about 12 m/s. Drag the wind slider and watch the dot move along the curve.", - "p2": "No turbine can catch all of it. To take energy, the rotor has to slow the air down, yet the air must still flow out behind it. The physicist Albert Betz showed that even a perfect rotor can take at most 59.3 % of the power. That is the Betz limit. This turbine's best is just under half, at about 7 to 8 m/s, and the chart shows how close it comes.", - "p3": "Above full power the wind brings more than the generator can take. So the pitch cylinders turn the blades a little out of the wind, and most of it slips past. At 15 m/s the turbine keeps only about 12 % of the power in the wind. The pitch angles shown above full power are this model's own, since the V150's are not published. Above 20 m/s, power starts to ramp down.", - "p4": "At the cut-out speed, about 22 to 25 m/s, the blades turn edge-on to the wind. This is feathering: the rotor slows on its own, and then the disc brake parks it. The turbine starts again only once the wind has dropped by about 2 m/s. In this model all 27 turbines stop together at 24.5 m/s and restart at 22.5 m/s. The stop and the restart each take 20 minutes of the clock, longer than real, so you can watch them." + "p1": "The rotor waits until the wind reaches 3 m/s, the cut-in speed. Then power climbs fast. The power in the wind grows with the speed cubed: speed times speed times speed. Double the wind, and it carries eight times the power. So the turbine makes about 0.3 MW at 4 m/s, about 2.5 MW at 8 m/s and its full 4.2 MW from about 12 m/s. Drag the wind slider and watch the dot move along the curve.", + "p2": "No turbine can catch all of that power. To take energy, the rotor must slow the air down, but the air still has to flow away behind it. If the rotor stopped the air completely, no new air could get through. The physicist Albert Betz showed that even a perfect rotor can take at most 59.3 % of the wind's power. That is the Betz limit. This turbine's best is just under half, at about 7 to 8 m/s. The chart shows how close it comes.", + "p3": "Above full power the wind brings more than the generator can take. So the pitch cylinders turn the blades a little out of the wind, and most of the wind slips past. At 15 m/s the turbine keeps only about 12 % of the power in the wind. The pitch angles this model shows above full power are its own, since the V150's are not published. Above 20 m/s, the turbine starts to ramp its power down.", + "p4": "At the cut-out speed, about 22 to 25 m/s, the wind is too strong to work in. The blades turn edge-on to the wind. This is feathering: the rotor slows down on its own, and then the disc brake parks it. The turbine starts again only once the wind has dropped by about 2 m/s. In this model all 27 turbines stop together at 24.5 m/s and restart at 22.5 m/s. Stopping and restarting each take 20 minutes on the clock, longer than in real life, so you can watch them." }, "wakes": { "title": "Why turbines stand so far apart", - "p1": "A turbine takes energy out of the wind, so the air behind it moves slower. This trail of slow, stirred air is called a wake. Switch on Wakes (K) to see a plume behind every turning rotor. Wakes are strongest in moderate wind, when the rotor holds back the air the hardest. Above full power the blades let more wind slip past, and the wakes fade.", - "p2": "This model draws wakes with a simple, widely used recipe, the Jensen model. It treats each wake as a cone that widens and slowly fills back up with faster air. How fast it widens is set by one number, 0.075 here, the usual value for land. At 8 m/s, a turbine 7 D downwind still meets air about 13 % slower. Because power follows the wind cubed, that costs it about a third of its power.", - "p3": "Over a year, wakes cost a farm about 5 to 15 % of its energy. A Vestas example farm of 24 of these turbines loses about 6 %. For a single wind direction, the loss can pass 30 %. That is why rows usually stand 5 to 9 D apart along the wind and 3 to 5 D apart across it. Try 5, 7 and 9 D: wider gaps lose less wind but need more land.", - "p4": "Wind also grows with height, because the ground drags on the air near it. Switch on Airflow (F) to see the profile: the top of the rotor meets faster wind than the bottom. The plumes and arrows are drawings of invisible air. In this model the ground is flat, a stopped rotor leaves no wake, and wakes that overlap add up in a simple way." + "p1": "A turbine takes energy out of the wind, so the air behind it moves slower. This trail of slow, stirred-up air is called a wake, like the trail behind a boat. Switch on Wakes (K) to see a plume behind every turning rotor. Wakes are strongest in moderate wind, when the rotor holds back the air the most. Above full power the blades let more wind slip past, and the wakes fade.", + "p2": "This model draws wakes with a simple, widely used recipe, the Jensen model. It treats each wake as a cone that widens and slowly fills back up with faster air. One number sets how fast it widens: 0.075 here, the usual value on land. At 8 m/s, a turbine 7 D downwind still meets air about 13 % slower. Power follows the speed cubed, so that costs it about a third of its power.", + "p3": "Over a year, wakes cost a farm about 5 to 15 % of its energy. In a Vestas example farm of 24 of these turbines, the loss is about 6 %. When the wind comes from one unlucky direction, the loss can pass 30 %. That is why rows usually stand 5 to 9 D apart along the wind, and turbines 3 to 5 D apart across it. Try 5, 7 and 9 D: wider gaps lose less to wakes but need more land.", + "p4": "Wind also gets stronger with height, because the ground drags on the air near it. Switch on Airflow (F) to see this: the top of the rotor meets faster wind than the bottom. The plumes and arrows are only drawings. In this model the ground is flat, a stopped rotor leaves no wake, and overlapping wakes add up in a simple way." }, "grid": { "title": "The road to the grid", - "p1": "Each generator makes power at 800 volts (V). After the converter, the turbine's own transformer raises the voltage. Higher voltage carries the same power with less current, so less of it is lost as heat in the cables. Switch on Cables (C) to follow the power from the turbines to the grid.", - "p2": "Collector cables at 36,000 volts, or 36 kilovolts (kV), link the turbines to the substation. There another transformer raises the voltage to 110 kV for the grid line. These voltages come from a Vestas example farm in Germany.", - "p3": "At full power, one turbine makes 4.2 megawatt hours (MWh) every hour. But the wind is not always strong, so over a year a turbine averages about 40 % of its full power. That share is the capacity factor. In a year, one turbine makes about 14,700 MWh, enough for about 1,400 US homes. The model's made-up day is windier than a year's average, so its capacity factor comes out higher.", - "p4": "Making a turbine costs energy and carbon too. Over its life, it adds about 7 g of CO2e for each kilowatt hour (kWh) it makes, where CO2e counts every greenhouse gas as if it were carbon dioxide. It pays back the energy used to build it in about 8 months. It is designed for 20 years and often runs longer, though its blades are still hard to recycle. Pick a calm, typical or windy site to see how much the everyday wind matters." + "p1": "Each generator makes power at 800 volts (V). After the converter, the turbine's own transformer raises the voltage. At a higher voltage the same power needs less current, so the cables waste less of it as heat. Switch on Cables (C) to follow the power from the turbines to the grid.", + "p2": "Collector cables at 36,000 volts, or 36 kilovolts (kV), link the turbines to the substation. There another transformer raises the voltage again, to 110 kV, for the grid line. These voltages come from a Vestas example farm in Germany.", + "p3": "At full power, one turbine makes 4.2 megawatt hours (MWh) every hour. But the wind is not always strong. Over a year, a turbine averages about 40 % of its full power. That share is its capacity factor. In a year, one turbine makes about 14,700 MWh, enough for about 1,400 US homes. The model's made-up day is windier than an average day, so its capacity factor comes out higher.", + "p4": "Building a turbine costs energy and carbon too. Over its whole life, it causes about 7 g of CO2e for each kilowatt hour (kWh) it makes. CO2e counts every greenhouse gas as if it were carbon dioxide. A turbine pays back the energy used to build it in about 8 months. It is designed for 20 years and often runs longer, though its blades are still hard to recycle. Pick a calm, typical or windy site to see how much the everyday wind matters." } } } From 1b963e31648967ece5aa611d2bdcde05baf7c2ad Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:24:15 +0200 Subject: [PATCH 031/132] fix(wind-farm): make the Japanese copy read naturally --- explainers/wind-farm/locales/ja.json | 84 ++++++++++++++-------------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/explainers/wind-farm/locales/ja.json b/explainers/wind-farm/locales/ja.json index 4d856422..38cba3fe 100644 --- a/explainers/wind-farm/locales/ja.json +++ b/explainers/wind-farm/locales/ja.json @@ -2,8 +2,8 @@ "meta": { "title": "風力発電所のしくみ", "eyebrow": "再生可能エネルギー", - "tagline": "27基の風車と、風の強い一日。翼の形をしたブレードが、動く空気を電気に変えて送電網へ送るしくみです。", - "description": "風の強い一日を3Dの風力発電所で。ブレードが風をとらえるしくみ、ナセルの中で回るもの、風車どうしが離れている理由、電気が送電網に届くまでを見ていきます。", + "tagline": "27基の風車と、風の強い一日。翼の形をしたブレードは、どうやって風を電気に変え、送電網へ送るのでしょう。", + "description": "3Dの風力発電所で、風の強い一日をたどります。ブレードが風をとらえるしくみ、ナセルの中で回るもの、風車の間隔が広い理由、電気が送電網に届くまで。", "summary": "発電所の配置、タワーとブレード、増速機と発電機、パワーカーブとベッツの限界、後流、そして送電網までの道のり。", "socialAlt": "なだらかな丘に広がる風力発電所と、手前に大きく写る風車1基、その横に「風力発電所のしくみ」というタイトル" }, @@ -13,12 +13,12 @@ }, "timeline": { "label": "時刻", - "phases": "一日の場面へ移動", + "phases": "一日の時間帯へ移動", "speed": "再生速度", "value": "{{time}}", "during": "{{time}}、{{phase}}", "speedValue": "{{value}}時間/分", - "speedFormat": "1分あたり一日の{{value}}時間分", + "speedFormat": "1分で{{value}}時間進む速さ", "phase": { "night": "風の弱い夜", "morning": "朝のそよ風", @@ -29,7 +29,7 @@ "jump": { "night": "風の弱い夜へ移動", "morning": "朝のそよ風へ移動", - "afternoon": "フル出力へ移動", + "afternoon": "フル出力の時間へ移動", "storm": "嵐へ移動", "evening": "夕方へ移動" } @@ -52,11 +52,11 @@ "open": "カバーなし" }, "windAtOptions": { - "day": "一日の流れどおり", + "day": "時刻に合わせる", "cutIn": "カットイン(3m/s)", "peak": "最高効率(8m/s)", "rated": "フル出力(12m/s)", - "rampDown": "出力を絞る(23m/s)", + "rampDown": "出力を下げる(23m/s)", "cutOut": "カットアウト超え(25m/s)" }, "groups": { @@ -94,11 +94,11 @@ "farm": "発電所全体", "state": { "idle": "風待ち", - "partial": "部分負荷", + "partial": "フル出力未満", "full": "フル出力", - "rampDown": "出力を絞り中", - "stopping": "フェザリングで停止中", - "parked": "停止", + "rampDown": "出力低減中", + "stopping": "フェザリングで減速中", + "parked": "停止中", "starting": "起動中" } }, @@ -116,18 +116,18 @@ "hectares": "{{value}}ha", "squareKm": "{{value}}km²", "volts": "{{value}}V", - "gramsPerKwh": "1kWhあたり{{value}}g(CO2e換算)", + "gramsPerKwh": "1kWhあたり{{value}}g(CO2e)", "months": "{{value}}か月" }, "chapters": { "farm": { "count": "風車の数", "rated": "発電所のフル出力", - "rows": "列の間隔(風の向き)", - "across": "同じ列の風車の間隔", + "rows": "列の間隔(風下方向)", + "across": "列内の風車の間隔", "spacingValue": "{{metres}}m、{{diameters}}D", - "built": "実際に建設に使う土地", - "project": "発電所の区域全体" + "built": "建設に使う土地", + "project": "事業区域全体" }, "tower": { "wind": "ハブの高さの風", @@ -135,7 +135,7 @@ "turn": "1回転にかかる時間", "stopped": "ローター停止中", "tip": "ブレード先端の速さ", - "height": "ブレード先端の最高点", + "height": "最高到達高さ", "blade": "ブレードの長さ", "swept": "受風面積", "sweptValue": "{{area}}m²、サッカー場およそ{{pitches}}面分" @@ -143,7 +143,7 @@ "nacelle": { "shaft": "主軸", "generator": "発電機", - "gear": "ギア比", + "gear": "増速比", "gearValue": "約1対{{ratio}}", "pitch": "ブレードのピッチ角", "brake": "ディスクブレーキ", @@ -225,45 +225,45 @@ "sections": { "farm": { "title": "27基の風車と、風の強い一日", - "p1": "これは、なだらかな畑に27基の風車が並ぶ風力発電所です。どれもヴェスタスのV150で、フル出力では1基で4.2メガワット(MW)を発電します。メガワットは100万ワットのことで、発電所全体ではおよそ113MWになります。規模は、スウェーデンにある実在の発電所モーラルベリエットと同じです。そこでも同じ風車27基が2021年から動いています。", - "p2": "風車は列をつくり、卓越風を横切るように並んでいます。卓越風とはいちばんよく吹いてくる風の向きのことで、ここでは西風です。技術者は間隔を「ローターの直径いくつ分か」で表し、1つ分をDと書きます。ここでは1Dが150mで、列どうしは7D(1,050m)、同じ列の風車どうしは4D(600m)離れています。列ごとに横へ少しずらしてあるので、前の列の風車の真後ろに立つ風車はありません。", - "p3": "上から見るとぎっしり並んで見えますが、土地のほとんどは今も畑のままです。実際に建設に使うのは1メガワットあたり約0.3ヘクタールだけで、その大半は風車の土台ではなく管理用道路です。1ヘクタールは、1辺100mの正方形の広さです。電気は集電ケーブルを通って変電所に集まり、そこから送電網へ向かいます。", - "p4": "モーラルベリエットの実際の配置は公開されていないので、ここに見える配置はこのモデル独自のものです。モデルでは、架空の一日の風をくり返し流しています。平均的な一日ではなく、風の弱い夜、そよ風の朝、フル出力の午後、夕方の嵐を24時間に詰めこんだ一日です。時計は実際の何百倍もの速さで進みますが、ローターはどれも本当の速さで回ります。道路やケーブル、風の矢印は何kmも先から見えるよう実物より太く描いていて、日の光は時刻に合わせて変わりません。" + "p1": "これは風力発電所です。なだらかな畑に、27基の風車が散らばって立っています。どれもヴェスタス社のV150で、1基がフル出力で4.2メガワット(MW)を発電します。メガワットは100万ワットのことなので、発電所全体ではおよそ113MWです。これは、スウェーデンにあるモーラルベリエットと同じ規模です。同じ風車27基が並ぶ実在の発電所で、2021年から動いています。", + "p2": "風車は列をつくり、卓越風を横切るように並んでいます。卓越風とは、いちばんよく吹いてくる風の向きのことです。ここでは西から吹く風です。技術者は、風車の間隔をローターの直径いくつ分かで表し、1つ分をDと書きます。1Dは、ブレードの先端から先端までの幅で、ここでは150mです。列どうしは7D、つまり1,050m離れています。同じ列の中では、風車どうしは4D、つまり600mの間隔です。列ごとに少しずつ横へずらしてあるので、ほかの風車の真後ろに隠れる風車はありません。", + "p3": "上から見るとぎっしり並んで見えますが、土地のほとんどは今も畑のままです。実際に建設に使う土地は、1メガワットあたり約0.3ヘクタールだけです。1ヘクタールは、1辺100mの正方形の広さです。しかもその大半は、風車の土台ではなく管理用道路です。集電ケーブルがすべての風車から電気を集め、変電所へ運びます。電気はそこで送電網に合流します。", + "p4": "モーラルベリエットの実際の配置は公開されていません。そのため、ここに見える配置はこのモデル独自のものです。風の一日も架空のもので、くり返し再生されます。平均的な一日ではありません。風の弱い夜、そよ風の朝、フル出力の午後、夕方の嵐を、24時間にぎゅっと詰めこんでいます。時計は実際の何百倍もの速さで進みますが、ローターはどれも本当の速さで回ります。道路やケーブル、風の矢印は、何kmも離れたところから見えるように、実物より太く描いています。日の光は、どの時刻でも変わりません。" }, "tower": { "title": "円を描いて飛ぶブレード", - "p1": "近づいてみると、風車1基でも見上げるほどの大きさです。鋼鉄のタワーがハブを地上105mまで持ち上げ、ハブと3枚のブレードからなるローターは直径150mあります。回転のいちばん上では、ブレードの先端が約180mに届き、ビッグ・ベンのほぼ2倍の高さになります。第5章で見るように、その高さでは地面の近くより風が速く吹いています。", - "p2": "ブレードは1枚が約74mあり、最大の旅客機エアバスA380の翼幅とほぼ同じ長さです。炭素繊維とガラス繊維を、硬いプラスチックの接着剤であるエポキシ樹脂で固めてつくられています。ブレードは、風に押される帆ではありません。風は飛行機の翼の上を流れるようにブレードの表面を流れ、曲がった形が揚力、つまり風を横切る向きの力を生みます。この揚力が1枚1枚のブレードを引っぱり、ローターが回ります。", - "p3": "全速でも、ローターは1分間に約10回転、つまり10rpm(毎分回転数)しか回りません。6秒に1回転なので、のんびりして見えます。でも先端は大きな円を描くので、その速さは約80m/s、時速300km近くになります。風速スライダーを動かすと風に合わせてローターが速くなり、「一日の流れどおり」を選ぶと、風はまた時計に合わせて変わります。このモデルでは、ローターが最高速度に達するまで、先端は風の8倍の速さで動きます。", - "p4": "ブレードが回って描く円を受風面積といい、約17,700m²、サッカー場およそ2.5面分の広さです。ローターを回せるのは、この円を通り抜ける風だけです。本物の空気は目に見えないので、ローターのまわりの空気の線は図として描いたものです。V150の変圧器がどこにあるかは公開されていないため、このモデルではタワーの足もとに置いています。同じ理由で、ローターのわずかな傾きも描いていません。" + "p1": "近くで見ると、風車1基でも見上げるほどの大きさです。ローターは、丸いハブに3枚のブレードを取りつけたもので、直径は150mあります。鋼鉄のタワーが、ハブを地上105mの高さまで持ち上げています。回転のいちばん上では、ブレードの先端が約180mに届きます。ビッグ・ベンのほぼ2倍の高さです。第5章で見るように、その高さでは地面の近くより風が速く吹いています。", + "p2": "ブレードは1枚が約74mあります。世界最大の旅客機、エアバスA380の翼幅とほぼ同じ長さです。炭素繊維とガラス繊維を、硬いプラスチックの接着剤であるエポキシ樹脂で固めてつくられています。風は、帆を押すようにブレードを押すわけではありません。飛行機の翼と同じように、ブレードの表面を流れていきます。ふくらんだ形のおかげで、揚力が生まれます。揚力とは、風の向きに対して直角にはたらく力のことです。この揚力がブレードを1枚ずつ引っぱり、ローターが回ります。", + "p3": "全速でも、ローターは1分間に約10回転しかしません。これを10rpm(毎分回転数)と書きます。6秒で1回転なので、のんびり回っているように見えます。でも先端は大きな円を描くので、約80m/s、時速300km近くで動いています。風速スライダーを動かしてみましょう。風に合わせてローターが速くなります。「時刻に合わせる」を選ぶと、風はまた時計どおりに戻ります。このモデルでは、ローターが最高速度に達するまで、先端は風の8倍の速さで動きます。", + "p4": "ブレードが回って描く円を受風面積といいます。約17,700m²で、サッカー場およそ2.5面分の広さです。ローターを回せるのは、この円を通り抜ける風だけです。本物の空気は目に見えないので、ローターのまわりの空気の線は図として描いたものです。風車の変圧器は、ケーブルで送れるように電気の電圧を上げる装置です。V150では変圧器の場所が公開されていないので、このモデルではタワーの足もとに置いています。同じ理由で、ローターのわずかな傾きも描いていません。" }, "nacelle": { "title": "ナセルの中", - "p1": "ハブの後ろにある長い箱がナセルで、長さは12.8mあります。「カバーなし」を選ぶと、中をのぞけます。中には、ローターのゆっくりした力強い回転を電気に変える機械が詰まっています。機械はどれも重い鋳鉄の台であるベッドプレートに載っていて、屋根の冷却器がその熱を外へ逃がします。", - "p2": "ハブの中では、ブレードが1枚ずつ軸受に取りつけられていて、ひねるように向きを変えられます。ブレード1枚に1本ずつ、計3本のピッチシリンダーが油圧でブレードをひねります。風に対するブレードの角度をピッチ角といい、風がどれだけブレードをつかめるかはこれで決まります。ハブの後ろでは主軸が主軸受の中で、ローターと同じ約10rpmのゆっくりした速さで回っています。", - "p3": "発電機は速く回す必要があるので、あいだに増速機(ギアボックス)が入っています。中には、中心の歯車のまわりを小さな歯車が回る遊星歯車の段が2つと、歯がななめについたはすば歯車の段が1つあります。これらを合わせて回転を約140倍に速め、最大で約1,485rpmにします。発電機は誘導発電機で、銅線を巻いたコイルを使い、磁石は使いません。800ボルトでつくられた電気はすべてコンバーターを通り、送電網に合う形に整えられます。", - "p4": "軸にはディスクブレーキがついています。でも嵐のときに風車を止めるのはこのブレーキではなく、縁を風に向けたブレードです。ブレーキは、回転が遅くなったローターを止めておくためだけに使います。タワーのてっぺんではヨー駆動装置がナセルごと回して、風の来る方へ向けます。V150が実際にどれくらいの速さで向きを変えるかは公開されていないため、このモデルではナセルが風にぴったりついていきます。" + "p1": "ハブの後ろにある箱がナセルで、長さは12.8mあります。「カバーなし」を選ぶと、中をのぞけます。中には、ローターのゆっくりで力強い回転を電気に変える機械が詰まっています。機械はどれも、重い鋳鉄の台であるベッドプレートの上に載っています。屋根の冷却器が、機械の熱を外へ逃がします。", + "p2": "風に対するブレードの角度をピッチ角といいます。ブレードがどれだけ風をつかめるかは、この角度で決まります。ブレードは1枚ずつ軸受でハブに取りつけられていて、ひねるように向きを変えられます。ブレード1枚に1本ずつ、計3本のピッチシリンダーが、油圧でブレードをひねります。ハブの後ろでは、主軸が主軸受に支えられて回っています。速さはローターと同じ、約10rpmのゆっくりした回転です。", + "p3": "発電機には速い回転が必要です。そこで、あいだに増速機(ギアボックス)が入っています。中には遊星歯車の段が2つと、はすば歯車の段が1つあります。遊星歯車は、中心の歯車のまわりを小さな歯車が回るしくみです。はすば歯車は、歯がななめについた歯車です。これらを合わせると回転は約140倍に速まり、最大で約1,485rpmになります。発電機は誘導発電機で、銅線を巻いたコイルを使い、磁石は使いません。電気は800ボルトで出てきて、そのすべてがコンバーターを通ります。コンバーターは電力変換装置で、電気を送電網と足並みのそろう形に整えます。", + "p4": "軸にはディスクブレーキがついています。でも、嵐のときに風車を止めるのはこのブレーキではありません。止めるのはブレードのほうで、縁を風に向けて回転を弱めます。ブレーキは、ゆっくりになったローターをその場に固定しておくためだけに使います。タワーのてっぺんでは、ヨー駆動装置がナセルごと回して、風の来る方へ向けます。この向きを変える動きを「ヨー」と呼びます。V150が実際にどれくらいの速さで向きを変えるかは公開されていません。そのため、このモデルではナセルが風にぴったりついていきます。" }, "curve": { "title": "風車はどれだけ風をつかめるか", - "p1": "カットイン風速の3m/sまでローターは待機し、それを超えると出力が一気に伸びます。風のもつパワーは風速の3乗、つまり風速×風速×風速で増えるからです。風が2倍になれば、運ぶパワーは8倍です。そのため風車の出力は、4m/sで約0.3MW、8m/sで約2.5MW、約12m/sからはフル出力の4.2MWになります。風速スライダーを動かして、点がカーブの上を動くのを見てみましょう。", - "p2": "風のパワーをすべてとらえられる風車はありません。エネルギーを取り出すにはローターが空気を減速させる必要がありますが、それでも空気は後ろへ流れ出ていかなければなりません。物理学者のアルベルト・ベッツは、完璧なローターでも取り出せるのはパワーの59.3%までだと示し、これをベッツの限界といいます。この風車がいちばんよく取り出せるのは約7から8m/sのときで、その割合は半分弱です。どこまで限界に近づくかはグラフでわかります。", - "p3": "フル出力を超えると、風は発電機が受けとめきれないほどのパワーを運んできます。そこでピッチシリンダーがブレードを少し風からそらし、風の大部分を素通りさせます。15m/sでは、風車が使うのは風のパワーのわずか約12%です。V150の値は公開されていないため、フル出力より上で表示しているピッチ角はこのモデル独自のものです。20m/sを超えると、出力は少しずつ下がりはじめます。", - "p4": "約22から25m/sのカットアウト風速になると、ブレードは縁を風に向けます。これをフェザリングといい、ローターは自然に減速して、最後にディスクブレーキが止めます。風が約2m/s弱まるまで、風車は再び動き出しません。このモデルでは、27基すべてが24.5m/sでいっせいに止まり、22.5m/sで動き出します。止まるのも動き出すのもモデルの時計でそれぞれ20分かかり、実際より長くしてあるので、じっくり見られます。" + "p1": "ローターは、風が3m/sになるまで待っています。この、発電を始める風速をカットイン風速といいます。そこから出力は一気に伸びます。風のもつパワーは、風速の3乗、つまり風速×風速×風速で増えるからです。風が2倍になれば、運ぶパワーは8倍になります。そのため風車の出力は、4m/sで約0.3MW、8m/sで約2.5MWです。約12m/sからは、フル出力(定格出力)の4.2MWに達します。風速スライダーを動かして、点がカーブの上を動くのを見てみましょう。", + "p2": "でも、そのパワーをすべてとらえられる風車はありません。エネルギーを取り出すには、ローターが空気を減速させる必要があります。それでも空気は、後ろへ流れ去らなければなりません。もしローターが空気を完全に止めてしまったら、新しい空気が入ってこられなくなります。物理学者のアルベルト・ベッツは、完璧なローターでも、取り出せるのは風のパワーの59.3%までだと示しました。これがベッツの限界です。この風車がいちばんよく取り出せるのは約7から8m/sのときで、その割合は半分弱です。限界にどこまで近づくかは、グラフで確かめられます。", + "p3": "フル出力を超えると、風は発電機が受けとめきれないほどのパワーを運んできます。そこでピッチシリンダーがブレードを少しだけ風からそらし、風の大部分を素通りさせます。15m/sでは、風車が使うのは風のパワーのわずか約12%です。V150の実際のピッチ角は公開されていないので、フル出力より上でこのモデルが見せるピッチ角はモデル独自のものです。20m/sを超えると、風車は出力を少しずつ下げはじめます。", + "p4": "風が強すぎて運転できなくなる風速をカットアウト風速といい、約22から25m/sです。そうなると、ブレードは縁を風に向けます。これをフェザリングといいます。ローターは自然に減速し、そのあとディスクブレーキで止めておきます。風車が再び動き出すのは、風が約2m/s弱まってからです。このモデルでは、27基すべてが24.5m/sでいっせいに止まり、22.5m/sで動き出します。止まるのにも動き出すのにも、モデルの時計でそれぞれ20分かかります。実際より長くしてあるので、その様子をじっくり見られます。" }, "wakes": { "title": "風車どうしがこれほど離れている理由", - "p1": "風車は風からエネルギーを取り出すので、その後ろの空気は遅くなります。この遅くかき乱された空気の流れを後流(ウェイク)と呼びます。「後流(K)」をオンにすると、回っているローターすべての後ろに伸びる帯が見えます。後流がいちばん強くなるのは中くらいの風のときで、ローターが空気をもっとも強く押しとどめるからです。フル出力を超えるとブレードが風を多く素通りさせるので、後流は弱まります。", - "p2": "このモデルは、広く使われているシンプルな計算方法のジェンセンモデルで後流を描いています。後流を、広がりながら周りの速い空気で少しずつ埋め戻されていく円すいとして扱う方法です。広がる速さは1つの数字で決まり、ここでは陸上でふつう使われる0.075です。風速8m/sのとき、7D風下の風車にはまだ約13%遅い風が届きます。出力は風速の3乗で決まるので、それだけでその風車は出力の約3分の1を失います。", - "p3": "1年を通すと、後流によって発電所は発電量の約5から15%を失います。ヴェスタスが例として示す、この風車24基の発電所では、損失は約6%です。ある1つの風向きだけで見ると、損失は30%を超えることもあります。そのため列の間隔はふつう、風の向きに5から9D、風を横切る向きに3から5Dとります。5D、7D、9Dを試すと、間隔が広いほど失う風は減るものの、そのぶん広い土地が必要になるのがわかります。", - "p4": "風は高いところほど速くなります。地面が近くの空気にブレーキをかけるからです。「気流(F)」をオンにすると高さごとの風の変化が見え、ローターの上端には下端より速い風が当たっているのがわかります。帯や矢印は、目に見えない空気を図にしたものです。このモデルでは地面は平らで、止まったローターは後流をつくらず、重なった後流は単純な方法で足し合わせています。" + "p1": "風車は風からエネルギーを取り出すので、その後ろの空気は遅くなります。この遅くかき乱された空気の流れを後流(ウェイク)と呼びます。船が通ったあとに残る航跡のようなものです。「後流(K)」をオンにすると、回っているローターすべての後ろに、尾のような帯が見えます。後流がいちばん強くなるのは、中くらいの風のときです。ローターが空気をもっとも強く押しとどめるからです。フル出力を超えると、ブレードが風を多く素通りさせるので、後流は弱まります。", + "p2": "このモデルは、広く使われているシンプルな計算方法、ジェンセンモデルで後流を描いています。後流を円すいとみなし、広がりながら周りの速い空気で少しずつ埋め戻されていくと考えます。広がる速さは1つの数字で決まります。ここでは、陸上でふつう使われる0.075です。風速8m/sのとき、7D風下にある風車には、まだ約13%遅い風が届きます。出力は風速の3乗で決まるので、それだけでこの風車は出力の約3分の1を失います。", + "p3": "1年を通すと、後流によって発電所は発電量の約5から15%を失います。ヴェスタスが例に挙げる、この風車24基の発電所では、損失は約6%です。風が運悪く特定の向きから吹くと、損失が30%を超えることもあります。そのため列の間隔は、風の向きにふつう5から9D、風を横切る向きに3から5Dとります。5D、7D、9Dを試してみましょう。間隔を広げるほど後流で失う分は減りますが、そのぶん広い土地が必要になります。", + "p4": "風は、高いところほど強くなります。地面が、近くの空気を引きとめるからです。「気流(F)」をオンにすると、その様子が見えます。ローターの上端には、下端より速い風が当たっています。帯や矢印は、あくまで図として描いたものです。このモデルでは地面は平らで、止まったローターは後流をつくらず、重なった後流は単純な方法で足し合わせています。" }, "grid": { "title": "送電網までの道のり", - "p1": "どの発電機も800ボルト(V)で電気をつくります。コンバーターを通ったあと、風車ごとの変圧器が電圧を上げます。電圧が高いほど同じ電力を少ない電流で送れるので、ケーブルで熱として失われる分が減ります。「ケーブル(C)」をオンにすると、風車から送電網までの電気の流れをたどれます。", - "p2": "36,000ボルト、つまり36キロボルト(kV)の集電ケーブルが、風車と変電所を結んでいます。変電所では別の変圧器が電圧を110kVまで上げ、送電線へ送り出します。これらの電圧は、ヴェスタスが例として示すドイツの発電所の値です。", - "p3": "フル出力なら、風車1基は1時間に4.2メガワット時(MWh)の電気をつくります。でも風はいつも強いわけではないので、1年を平均するとフル出力の約40%になります。この割合を設備利用率といいます。風車1基の発電量は1年で約14,700MWhで、米国の家庭約1,400世帯分をまかなえます。このモデルの架空の一日は年平均より風が強いので、設備利用率は高めに出ます。", - "p4": "風車をつくるのにもエネルギーがかかり、CO2も出ます。一生を通じて、発電する1キロワット時(kWh)あたり約7gのCO2e(すべての温室効果ガスを二酸化炭素に換算した量)を出します。建設に使ったエネルギーは、約8か月で取り戻せます。設計寿命は20年で、それより長く動くことも多いものの、ブレードのリサイクルはまだ難しいのが現状です。風の弱い場所、ふつうの場所、風の強い場所を選んで、ふだんの風がどれほど効いてくるか確かめてみましょう。" + "p1": "どの発電機も、800ボルト(V)で電気をつくります。コンバーターを通ったあと、風車ごとの変圧器が電圧を上げます。電圧が高いほど、同じ電力を少ない電流で送れます。そのため、ケーブルで熱として失われる分が減ります。「ケーブル(C)」をオンにして、風車から送電網までの電気の流れをたどってみましょう。", + "p2": "集電ケーブルは36,000ボルト、つまり36キロボルト(kV)で、風車と変電所を結んでいます。変電所では別の変圧器が電圧を110kVまで上げ、送電線へ送り出します。これらの電圧は、ヴェスタスが例に挙げるドイツの発電所の値です。", + "p3": "フル出力なら、風車1基は1時間に4.2メガワット時(MWh)の電気をつくります。でも、風はいつも強いわけではありません。1年を平均すると、風車の出力はフル出力の約40%です。この割合を設備利用率といいます。風車1基の発電量は1年で約14,700MWhで、米国の家庭約1,400世帯分をまかなえます。このモデルの架空の一日は平均的な一日より風が強いので、設備利用率は高めに出ます。", + "p4": "風車をつくるときにも、エネルギーを使い、CO2を出します。一生を通じて見ると、発電した電気1キロワット時(kWh)あたり約7gのCO2eを出します。CO2eとは、あらゆる温室効果ガスを二酸化炭素に換算した量です。建設に使ったエネルギーは、約8か月で取り戻せます。設計寿命は20年で、それより長く動くこともよくあります。ただ、ブレードのリサイクルはまだ難しいのが現状です。「風の弱い場所」「ふつうの場所」「風の強い場所」を選んで、ふだんの風がどれほど効いてくるか確かめてみましょう。" } } } From 95234fa6e8e3697cfb82238cff726f7e671a8b49 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:24:52 +0200 Subject: [PATCH 032/132] fix(wind-farm): make the Portuguese copy read naturally --- explainers/wind-farm/locales/pt.json | 68 ++++++++++++++-------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/explainers/wind-farm/locales/pt.json b/explainers/wind-farm/locales/pt.json index 3f48ee87..29ad139d 100644 --- a/explainers/wind-farm/locales/pt.json +++ b/explainers/wind-farm/locales/pt.json @@ -3,8 +3,8 @@ "title": "Como funciona um parque eólico", "eyebrow": "Energia renovável", "tagline": "Vinte e sete turbinas e um dia de muito vento: como uma pá com formato de asa transforma o movimento do ar em energia para a rede elétrica.", - "description": "Um parque eólico em 3D num dia de vento: como as pás captam o vento, o que gira na nacele, por que as turbinas ficam longe e como a energia chega à rede.", - "summary": "A disposição do parque, a torre e as pás, a caixa de engrenagens e o gerador, a curva de potência e o limite de Betz, as esteiras e o caminho até a rede.", + "description": "Parque eólico em 3D num dia de vento: como as pás captam o vento, o que gira na nacele, por que as turbinas ficam afastadas e como a energia chega à rede.", + "summary": "A disposição do parque, a torre e as pás, a caixa multiplicadora e o gerador, a curva de potência e o limite de Betz, as esteiras e o caminho até a rede.", "socialAlt": "Um parque eólico em terreno ondulado, com uma turbina em primeiro plano, ao lado do título “Como funciona um parque eólico”" }, "stage": { @@ -94,7 +94,7 @@ "farm": "Parque inteiro", "state": { "idle": "Esperando o vento", - "partial": "Carga parcial", + "partial": "Abaixo da potência máxima", "full": "Potência máxima", "rampDown": "Reduzindo a potência", "stopping": "Embandeirando para parar", @@ -109,7 +109,7 @@ "degrees": "{{value}}°", "mw": "{{value}} MW", "mwh": "{{value}} MWh", - "percent": "{{value}} %", + "percent": "{{value}}%", "metres": "{{value}} m", "seconds": "{{value}} s", "count": "{{value}}", @@ -148,7 +148,7 @@ "pitch": "Passo das pás", "brake": "Freio a disco", "brakeValue": { - "released": "Solto", + "released": "Liberado", "holding": "Segurando o rotor" }, "volts": "Tensão do gerador" @@ -171,8 +171,8 @@ "wakes": { "spacing": "Distância entre fileiras", "deficit": "Perda de velocidade na fileira seguinte", - "loss": "Perda do parque com esteiras agora", - "top": "Vento no alto do rotor", + "loss": "Perda atual do parque por esteiras", + "top": "Vento na parte de cima do rotor", "bottom": "Vento na parte de baixo do rotor" }, "grid": { @@ -185,11 +185,11 @@ "annual": "Uma turbina em um ano", "annualHomes": "Casas nos EUA que uma turbina abastece", "carbon": "Pegada de carbono", - "payback": "Retorno energético" + "payback": "Tempo de retorno energético" } }, "parts": { - "land": "Solo", + "land": "Terreno", "foundation": "Fundação", "tower": "Torre", "transformer": "Transformador", @@ -202,7 +202,7 @@ "pitchCylinders": "Cilindros de passo", "mainBearing": "Rolamento principal", "mainShaft": "Eixo principal", - "gearbox": "Caixa de engrenagens", + "gearbox": "Caixa multiplicadora", "brakeDisc": "Freio a disco", "generator": "Gerador", "converter": "Conversor", @@ -225,45 +225,45 @@ "sections": { "farm": { "title": "Vinte e sete turbinas e um dia de vento", - "p1": "Este é um parque eólico: 27 turbinas espalhadas por campos ondulados. Cada uma é uma Vestas V150 e, na potência máxima, gera 4,2 megawatts (MW). Um megawatt é um milhão de watts, então o parque inteiro pode gerar cerca de 113 MW. Ele tem o tamanho de Målarberget, na Suécia, um parque real com 27 dessas turbinas que funciona desde 2021.", - "p2": "As turbinas ficam em fileiras que cortam o caminho do vento predominante, a direção de onde o vento mais sopra. Aqui, ele vem do oeste. Os engenheiros medem as distâncias em diâmetros do rotor, escritos como D, e aqui um D vale 150 m. As fileiras ficam a 7 D umas das outras, ou 1.050 m, e as turbinas de uma mesma fileira ficam a 4 D, ou 600 m. Cada fileira é deslocada para o lado, para que nenhuma turbina fique bem atrás de outra da fileira da frente.", - "p3": "Visto de cima, o parque parece lotado, mas o terreno continua sendo, em grande parte, lavoura. Só cerca de 0,3 hectare por megawatt é de fato ocupado por construções, e a maior parte dessa área são estradas de acesso, não as bases das turbinas. Um hectare é um quadrado de 100 m de lado. A energia segue pelos cabos coletores até uma subestação, de onde parte para a rede elétrica.", - "p4": "A disposição real das turbinas em Målarberget não foi publicada, então a que você vê foi criada para este modelo. O modelo também repete sem parar um dia de vento inventado. Não é um dia médio: ele junta em 24 horas uma noite calma, uma manhã de brisa, uma tarde de potência máxima e uma tempestade no fim da tarde. O relógio corre centenas de vezes mais rápido que o tempo real, mas cada rotor gira na sua velocidade de verdade. Estradas, cabos e setas de vento aparecem mais largos do que são para que você os veja a quilômetros de distância, e a luz não acompanha o relógio." + "p1": "Este é um parque eólico: 27 turbinas espalhadas por campos ondulados. Cada uma é uma Vestas V150 e, na potência máxima, gera 4,2 megawatts (MW). Um megawatt é um milhão de watts. Ou seja, o parque inteiro pode gerar cerca de 113 MW. Os números batem com os de Målarberget, na Suécia: um parque de verdade, com 27 dessas turbinas, que funciona desde 2021.", + "p2": "As turbinas ficam lado a lado em fileiras, de frente para o vento predominante, aquele que mais sopra no lugar. Aqui, ele vem do oeste. Os engenheiros medem as distâncias em diâmetros do rotor, representados pela letra D. Um D é a largura do rotor de uma ponta de pá à outra: aqui, 150 m. As fileiras ficam a 7 D umas das outras, ou 1.050 m. Dentro de cada fileira, as turbinas ficam a 4 D, ou 600 m. Cada fileira é um pouco deslocada para o lado, para que nenhuma turbina fique escondida bem atrás de outra.", + "p3": "Visto de cima, o parque parece lotado, mas quase todo o terreno continua sendo lavoura. Só cerca de 0,3 hectare por megawatt tem alguma construção. Um hectare é um quadrado de 100 m de lado. E a maior parte dessa área construída são estradas de acesso, não as bases das turbinas. Os cabos coletores recolhem a energia de cada turbina e a levam até uma subestação, onde ela entra na rede elétrica.", + "p4": "A disposição real de Målarberget não foi publicada, então a que você vê aqui foi criada para o modelo. O dia de vento também é inventado e se repete sem parar. Não é um dia médio: em 24 horas, ele reúne uma noite calma, uma manhã de brisa, uma tarde de potência máxima e uma tempestade no começo da noite. O relógio corre centenas de vezes mais rápido que o tempo real, mas cada rotor gira na sua velocidade de verdade. Estradas, cabos e setas de vento aparecem mais largos do que são, para que você os enxergue a quilômetros de distância. A iluminação é a mesma a qualquer hora." }, "tower": { "title": "Pás que voam em círculos", - "p1": "De perto, uma turbina é enorme. A torre de aço ergue o cubo a 105 m do chão, e o rotor, o cubo com suas três pás, tem 150 m de diâmetro. No ponto mais alto da volta, a ponta de uma pá chega a cerca de 180 m, quase o dobro da altura do Big Ben. Lá em cima o vento sopra mais rápido do que perto do chão, como mostra o capítulo 5.", - "p2": "Cada pá tem cerca de 74 m de comprimento, quase a envergadura de um Airbus A380, o maior avião de passageiros. Ela é feita de fibra de carbono e fibra de vidro presas em epóxi, uma cola plástica dura. A pá não é uma vela que o vento empurra. O vento passa por ela como pela asa de um avião, e o formato curvo cria sustentação, uma força que age de lado em relação ao vento. A sustentação puxa cada pá ao longo do círculo, e o rotor gira.", - "p3": "Na velocidade máxima, o rotor dá só cerca de 10 voltas por minuto, ou 10 rpm (rotações por minuto). Isso dá uma volta a cada 6 segundos, o que parece até preguiçoso. Mas as pontas percorrem um círculo enorme, então andam a cerca de 80 m/s, perto de 300 km/h. Arraste o controle de vento para ver o rotor acelerar junto com o vento, ou escolha Seguir o dia para que o vento volte a seguir o relógio. Neste modelo, as pontas andam 8 vezes mais rápido que o vento até o rotor atingir sua velocidade máxima.", - "p4": "O círculo que as pás percorrem é a área varrida: cerca de 17.700 m², o tamanho de uns 2,5 campos de futebol. Só o vento que passa por esse círculo consegue girar o rotor. As linhas de ar em volta do rotor são um desenho, já que o ar de verdade é invisível. Neste modelo, o transformador fica no pé da torre, porque a posição dele na V150 não foi publicada. Pelo mesmo motivo, a leve inclinação do rotor não aparece no desenho." + "p1": "De perto, uma turbina é enorme. O rotor tem três pás presas a uma peça central redonda, o cubo. Ele mede 150 m de ponta a ponta. A torre de aço ergue o cubo a 105 m do chão. No alto da volta, a ponta de uma pá chega a cerca de 180 m, quase o dobro da altura do Big Ben. Lá em cima, o vento é mais rápido do que perto do chão, como mostra o capítulo 5.", + "p2": "Cada pá tem cerca de 74 m de comprimento, quase a envergadura de um Airbus A380, o maior avião de passageiros. Ela é feita de fibras de carbono e de vidro mergulhadas em epóxi, uma cola plástica que endurece. O vento não empurra a pá como faria com uma vela. O ar passa por ela do mesmo jeito que passa pela asa de um avião. O formato curvo gera sustentação, uma força perpendicular ao vento. É a sustentação que puxa cada pá ao longo do círculo, e assim o rotor gira.", + "p3": "Na velocidade máxima, o rotor dá só cerca de 10 voltas por minuto, ou 10 rpm (rotações por minuto). Isso dá uma volta a cada 6 segundos, um ritmo que parece preguiçoso. Só que as pontas percorrem um círculo enorme, e por isso andam a cerca de 80 m/s, perto de 300 km/h. Arraste o controle deslizante do vento e veja o rotor acelerar junto com ele. Escolha Seguir o dia para que o vento volte a acompanhar o relógio. Neste modelo, as pontas andam 8 vezes mais rápido que o vento, até o rotor chegar à sua velocidade máxima.", + "p4": "O círculo que as pás percorrem é a área varrida: cerca de 17.700 m², o equivalente a uns 2,5 campos de futebol. Só o vento que atravessa esse círculo consegue girar o rotor. As linhas de ar em volta do rotor são só um desenho, porque o ar de verdade é invisível. O transformador da turbina eleva a tensão, ou voltagem, da energia antes de ela seguir pelos cabos. A posição dele na V150 não é pública, então este modelo o coloca no pé da torre. Pelo mesmo motivo, a leve inclinação do rotor não aparece no desenho." }, "nacelle": { "title": "Dentro da nacele", - "p1": "A caixa comprida atrás do cubo é a nacele, com 12,8 m de comprimento. Escolha Sem cobertura para ver por dentro. Ela abriga as máquinas que transformam o giro lento e forte do rotor em eletricidade. Todas ficam sobre o chassi, uma estrutura pesada de ferro fundido, e um resfriador no teto leva o calor delas embora.", - "p2": "No cubo, cada pá fica sobre um rolamento, para poder girar em torno do próprio eixo. Três cilindros de passo, um por pá, fazem esse giro com pressão de óleo. O ângulo da pá em relação ao vento é o seu passo, e ele define quanto o vento consegue agarrar. Atrás do cubo, o eixo principal gira no rolamento principal na mesma velocidade lenta do rotor, cerca de 10 rpm.", - "p3": "Um gerador precisa girar rápido, então há uma caixa de engrenagens no meio do caminho. Ela tem dois estágios planetários, em que engrenagens pequenas giram em volta de uma central, e um estágio helicoidal, com dentes inclinados. Juntos, eles multiplicam a rotação do eixo por cerca de 140, chegando a uns 1.485 rpm. O gerador é do tipo de indução, com enrolamentos de cobre e sem ímãs. Toda a energia dele, a 800 volts, passa pelo conversor, que a ajusta para combinar com a rede.", - "p4": "No eixo fica um freio a disco. Não é ele que para a turbina numa tempestade: quem faz isso são as pás, que giram até ficar com a borda virada para o vento. O freio só segura o rotor quando ele já está lento, para deixá-lo travado. No alto da torre, motores de guinada giram a nacele inteira para que ela fique de frente para o vento. Neste modelo, ela acompanha o vento com exatidão, porque a velocidade real de giro da V150 não foi publicada." + "p1": "A caixa atrás do cubo é a nacele, com 12,8 m de comprimento. Escolha Sem cobertura para espiar lá dentro. Ali ficam as máquinas que transformam o giro lento e forte do rotor em eletricidade. Todas se apoiam no chassi, uma estrutura pesada de ferro fundido. Um resfriador no teto leva o calor delas embora.", + "p2": "O ângulo da pá em relação ao vento é o seu passo. É ele que define quanto vento a pá consegue agarrar. Cada pá se encaixa no cubo sobre um rolamento, para poder girar em torno do próprio eixo. Três cilindros de passo, um por pá, fazem esse giro com pressão de óleo. Atrás do cubo, o eixo principal gira apoiado no rolamento principal, na mesma velocidade lenta do rotor: cerca de 10 rpm.", + "p3": "O gerador precisa de um giro rápido, por isso há uma caixa multiplicadora no meio do caminho. É uma caixa de engrenagens com dois estágios planetários, em que engrenagens pequenas giram em volta de uma central, e um estágio helicoidal, com dentes inclinados. Juntos, eles fazem a saída girar cerca de 140 vezes mais rápido, até uns 1.485 rpm. O gerador é de indução: tem enrolamentos de cobre e nenhum ímã. Ele entrega a energia a 800 volts, e toda ela passa pelo conversor. O conversor remodela a energia para que ela fique sincronizada com a rede.", + "p4": "No eixo fica um freio a disco. Não é ele que para a turbina numa tempestade: quem faz isso são as pás, ao virar a borda para o vento. O freio só entra em ação quando o rotor já perdeu velocidade, para deixá-lo travado. No alto da torre, os motores de guinada giram a nacele inteira para que ela fique de frente para o vento. Esse giro se chama guinada. Neste modelo, a nacele acompanha o vento com exatidão, porque a velocidade real desse giro na V150 não foi publicada." }, "curve": { "title": "Quanto vento uma turbina consegue captar", - "p1": "Abaixo de 3 m/s, a velocidade de partida, o rotor espera. Acima dela, a potência sobe rápido, porque a potência do vento cresce com a velocidade ao cubo: velocidade vezes velocidade vezes velocidade. Com o dobro de vento, ele carrega oito vezes mais potência. Por isso a turbina gera cerca de 0,3 MW a 4 m/s, cerca de 2,5 MW a 8 m/s e sua potência máxima de 4,2 MW a partir de uns 12 m/s. Arraste o controle de vento e veja o ponto andar pela curva.", - "p2": "Nenhuma turbina consegue captar tudo. Para tirar energia, o rotor precisa frear o ar, mas o ar ainda tem que sair por trás dele. O físico Albert Betz mostrou que nem um rotor perfeito consegue tirar mais de 59,3 % da potência. Esse é o limite de Betz. No seu melhor momento, com ventos de cerca de 7 a 8 m/s, esta turbina capta pouco menos da metade, e o gráfico mostra o quanto ela chega perto do limite.", - "p3": "Depois da potência máxima, o vento traz mais energia do que o gerador aguenta. Então os cilindros de passo giram as pás um pouco para fora do vento, e a maior parte dele passa direto. A 15 m/s, a turbina fica com só cerca de 12 % da potência do vento. Os ângulos de passo mostrados nessa faixa foram criados para este modelo, já que os da V150 não foram publicados. Acima de 20 m/s, a potência começa a cair aos poucos.", - "p4": "Na velocidade de corte, cerca de 22 a 25 m/s, as pás giram até ficar com a borda virada para o vento. Isso é o embandeiramento: o rotor desacelera sozinho, e depois o freio a disco o trava. A turbina só volta a funcionar quando o vento cai cerca de 2 m/s. Neste modelo, as 27 turbinas param juntas a 24,5 m/s e voltam a girar a 22,5 m/s. A parada e a volta levam 20 minutos do relógio cada uma, mais do que na realidade, para que você possa acompanhar." + "p1": "O rotor espera o vento chegar a 3 m/s, a velocidade de partida. A partir daí, a potência sobe rápido. A potência do vento cresce com o cubo da velocidade: velocidade vezes velocidade vezes velocidade. Se o vento dobra, ele carrega oito vezes mais potência. Por isso, a turbina gera cerca de 0,3 MW a 4 m/s, cerca de 2,5 MW a 8 m/s e seus 4,2 MW de potência máxima a partir de uns 12 m/s. Arraste o controle deslizante do vento e veja o ponto andar pela curva.", + "p2": "Nenhuma turbina consegue captar toda essa potência. Para tirar energia, o rotor precisa frear o ar, mas o ar ainda tem que escapar por trás dele. Se o rotor parasse o ar por completo, nenhum ar novo conseguiria passar. O físico Albert Betz mostrou que nem um rotor perfeito consegue tirar mais de 59,3% da potência do vento. Esse é o limite de Betz. O melhor desta turbina fica um pouco abaixo da metade, com ventos de uns 7 a 8 m/s. O gráfico mostra o quanto ela chega perto do limite.", + "p3": "Depois que a turbina atinge a potência máxima, o vento mais forte traz mais energia do que o gerador aguenta. Então os cilindros de passo giram as pás um pouco para fora do vento, e a maior parte dele passa direto. A 15 m/s, a turbina aproveita só cerca de 12% da potência do vento. Os ângulos de passo que o modelo mostra nessa faixa foram criados para ele, porque os da V150 não foram publicados. Acima de 20 m/s, a turbina começa a reduzir a potência aos poucos.", + "p4": "Na velocidade de corte, algo entre 22 e 25 m/s, o vento fica forte demais para a turbina trabalhar. As pás giram até ficar com a borda virada para o vento. Isso se chama embandeiramento: o rotor desacelera sozinho, e depois o freio a disco o trava. A turbina só volta a funcionar quando o vento cai uns 2 m/s. Neste modelo, as 27 turbinas param juntas a 24,5 m/s e voltam a girar a 22,5 m/s. A parada e a retomada levam 20 minutos cada no relógio, mais do que na vida real, para que você consiga acompanhar." }, "wakes": { "title": "Por que as turbinas ficam tão afastadas", - "p1": "Uma turbina tira energia do vento, então o ar atrás dela anda mais devagar. Esse rastro de ar lento e agitado se chama esteira. Ative Esteiras (K) para ver uma pluma atrás de cada rotor em movimento. As esteiras são mais fortes com vento moderado, quando o rotor segura o ar com mais força. Depois da potência máxima, as pás deixam mais vento passar, e as esteiras vão sumindo.", - "p2": "Este modelo desenha as esteiras com uma receita simples e muito usada, o modelo de Jensen. Ele trata cada esteira como um cone que se alarga e aos poucos volta a se encher de ar mais rápido. A rapidez com que o cone se alarga depende de um único número, aqui 0,075, o valor comum para parques em terra. A 8 m/s, uma turbina 7 D mais adiante, no sentido do vento, ainda recebe ar cerca de 13 % mais lento. Como a potência segue o vento ao cubo, isso custa a ela cerca de um terço da potência.", - "p3": "Ao longo de um ano, as esteiras custam a um parque cerca de 5 a 15 % da energia. Um parque de exemplo da Vestas, com 24 dessas turbinas, perde cerca de 6 %. Para uma única direção de vento, a perda pode passar de 30 %. Por isso, as fileiras costumam ficar de 5 a 9 D umas das outras no sentido do vento, e as turbinas de cada fileira, de 3 a 5 D entre si. Experimente 5, 7 e 9 D: distâncias maiores perdem menos vento, mas precisam de mais terra.", - "p4": "O vento também aumenta com a altura, porque o chão freia o ar que está perto dele. Ative Fluxo de ar (F) para ver o perfil: o alto do rotor recebe vento mais rápido do que a parte de baixo. As plumas e as setas são desenhos de um ar que ninguém vê. Neste modelo, o terreno é plano, um rotor parado não deixa esteira, e as esteiras que se sobrepõem se somam de um jeito simples." + "p1": "Uma turbina tira energia do vento, então o ar atrás dela fica mais lento. Esse rastro de ar lento e agitado se chama esteira, como o rastro que um barco deixa na água. Ative Esteiras (K) para ver uma pluma atrás de cada rotor que está girando. As esteiras são mais fortes com vento moderado, quando o rotor segura mais o ar. Acima da potência máxima, as pás deixam passar mais vento, e as esteiras vão sumindo.", + "p2": "As esteiras aqui são desenhadas com uma receita simples e muito usada, o modelo de Jensen. Ele trata cada esteira como um cone que vai se abrindo e, aos poucos, volta a se encher de ar mais rápido. Um único número define a rapidez com que o cone se abre: aqui, 0,075, o valor usual para parques em terra firme. A 8 m/s, uma turbina 7 D mais adiante, na direção do vento, ainda recebe ar cerca de 13% mais lento. Como a potência segue o cubo da velocidade, essa turbina perde cerca de um terço da potência.", + "p3": "Ao longo de um ano, as esteiras tiram de um parque algo entre 5% e 15% da energia. Num parque de exemplo da Vestas, com 24 dessas turbinas, a perda fica em cerca de 6%. Quando o vento vem de uma direção azarada, a perda pode passar de 30%. Por isso, as fileiras costumam ficar entre 5 e 9 D umas das outras no sentido do vento, e as turbinas de uma mesma fileira, entre 3 e 5 D. Experimente 5 D, 7 D e 9 D: com mais espaço, as esteiras roubam menos energia, mas o parque precisa de mais terra.", + "p4": "O vento também fica mais forte com a altura, porque o chão freia o ar que passa perto dele. Ative Fluxo de ar (F) para ver isso: a parte de cima do rotor recebe vento mais rápido que a parte de baixo. As plumas e as setas são só desenhos. Neste modelo, o terreno é plano, um rotor parado não deixa esteira, e as esteiras que se sobrepõem se somam de um jeito simples." }, "grid": { "title": "O caminho até a rede", - "p1": "Cada gerador produz energia a 800 volts (V). Depois do conversor, o transformador da própria turbina eleva a tensão. Com tensão mais alta, a mesma potência passa com menos corrente, e assim menos energia se perde como calor nos cabos. Ative Cabos (C) para seguir a energia das turbinas até a rede.", - "p2": "Os cabos coletores, a 36.000 volts, ou 36 quilovolts (kV), ligam as turbinas à subestação. Lá, outro transformador eleva a tensão para 110 kV, a da linha de transmissão. Essas tensões vêm de um parque de exemplo da Vestas na Alemanha.", - "p3": "Na potência máxima, uma turbina gera 4,2 megawatts-hora (MWh) a cada hora. Mas o vento nem sempre é forte, então ao longo de um ano uma turbina rende em média cerca de 40 % da sua potência máxima. Essa parcela é o fator de capacidade. Em um ano, uma turbina gera cerca de 14.700 MWh, o bastante para umas 1.400 casas nos EUA. O dia inventado do modelo tem mais vento do que a média de um ano, por isso o fator de capacidade dele sai mais alto.", - "p4": "Fabricar uma turbina também custa energia e carbono. Ao longo da vida útil, ela soma cerca de 7 g de CO2e para cada quilowatt-hora (kWh) que gera, e o CO2e conta todo gás de efeito estufa como se fosse dióxido de carbono. Em cerca de 8 meses, ela gera de volta a energia usada para construí-la. Foi projetada para durar 20 anos e muitas vezes dura mais, embora as pás ainda sejam difíceis de reciclar. Escolha um local calmo, típico ou ventoso para ver o quanto o vento do dia a dia faz diferença." + "p1": "Cada gerador produz energia a 800 volts (V). Depois do conversor, o transformador da própria turbina eleva a tensão. Com tensão mais alta, a mesma potência precisa de menos corrente, e assim os cabos desperdiçam menos energia em forma de calor. Ative Cabos (C) para seguir a energia das turbinas até a rede.", + "p2": "Os cabos coletores, a 36.000 volts, ou 36 quilovolts (kV), ligam as turbinas à subestação. Lá, outro transformador eleva a tensão mais uma vez, para 110 kV, e a energia segue pela linha de transmissão. Esses valores de tensão vêm de um parque de exemplo da Vestas na Alemanha.", + "p3": "Na potência máxima, uma turbina gera 4,2 megawatts-hora (MWh) a cada hora. Mas o vento nem sempre é forte. Ao longo de um ano, uma turbina produz em média cerca de 40% da sua potência máxima. Essa porcentagem é o fator de capacidade. Em um ano, uma turbina gera cerca de 14.700 MWh, o bastante para umas 1.400 casas nos Estados Unidos. No dia inventado do modelo venta mais do que num dia médio, por isso o fator de capacidade dele sai mais alto.", + "p4": "Construir uma turbina também gasta energia e emite carbono. Ao longo de toda a vida útil, ela é responsável por cerca de 7 g de CO2e para cada quilowatt-hora (kWh) que gera. O CO2e conta todos os gases de efeito estufa como se fossem dióxido de carbono. Em cerca de 8 meses, a turbina devolve a energia gasta para construí-la. Ela é projetada para durar 20 anos e muitas vezes vai além, embora as pás ainda sejam difíceis de reciclar. Escolha entre Local calmo, Local típico e Local ventoso para ver o quanto o vento de todo dia faz diferença." } } } From 26e50dba22a0701baf26569d4d8d14bcd4d89011 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:24:56 +0200 Subject: [PATCH 033/132] fix(wind-farm): make the Ukrainian copy read naturally --- explainers/wind-farm/locales/uk.json | 68 ++++++++++++++-------------- 1 file changed, 34 insertions(+), 34 deletions(-) diff --git a/explainers/wind-farm/locales/uk.json b/explainers/wind-farm/locales/uk.json index 20e0a442..14bc0c0b 100644 --- a/explainers/wind-farm/locales/uk.json +++ b/explainers/wind-farm/locales/uk.json @@ -2,8 +2,8 @@ "meta": { "title": "Як працює вітрова електростанція", "eyebrow": "Відновлювана енергетика", - "tagline": "Двадцять сім турбін і один вітряний день: як лопать у формі крила перетворює рух повітря на електрику для мережі.", - "description": "Вітрова електростанція у 3D протягом вітряного дня: як лопаті ловлять вітер, що крутиться в гондолі, чому турбіни стоять далеко і як струм іде в мережу.", + "tagline": "Двадцять сім турбін і один вітряний день: як лопать, схожа на крило, перетворює рух повітря на електрику для мережі.", + "description": "Вітрова електростанція в 3D протягом вітряного дня: як лопаті ловлять вітер, що обертається в гондолі, чому турбіни стоять далеко і як струм іде в мережу.", "summary": "Розташування станції, башта й лопаті, редуктор і генератор, крива потужності й межа Беца, сліди за турбінами та шлях до мережі.", "socialAlt": "Вітрова електростанція серед пологих пагорбів, одна турбіна великим планом, поруч заголовок «Як працює вітрова електростанція»" }, @@ -28,7 +28,7 @@ }, "jump": { "night": "Перейти до тихої ночі", - "morning": "Перейти до ранкового вітерцю", + "morning": "Перейти до ранкового вітерця", "afternoon": "Перейти до повної потужності", "storm": "Перейти до бурі", "evening": "Перейти до вечора" @@ -38,7 +38,7 @@ "label": "Керування моделлю", "siteWind": "Вітер у місцевості", "siteWindOptions": { - "calm": "Тиха місцевість", + "calm": "Маловітряна місцевість", "typical": "Звичайна місцевість", "windy": "Вітряна місцевість" }, @@ -54,7 +54,7 @@ "windAtOptions": { "day": "За ходом дня", "cutIn": "Увімкнення, 3 м/с", - "peak": "Найкраща частка, 8 м/с", + "peak": "Найбільша частка, 8 м/с", "rated": "Повна потужність, 12 м/с", "rampDown": "Спад потужності, 23 м/с", "cutOut": "За межею вимкнення, 25 м/с" @@ -94,7 +94,7 @@ "farm": "Уся станція", "state": { "idle": "Чекає на вітер", - "partial": "Часткове навантаження", + "partial": "Неповна потужність", "full": "Повна потужність", "rampDown": "Спад потужності", "stopping": "Флюгування до зупинки", @@ -126,7 +126,7 @@ "rows": "Між рядами, уздовж вітру", "across": "Між турбінами в ряду", "spacingValue": "{{metres}} м, {{diameters}} D", - "built": "Забудовано землі", + "built": "Забудована земля", "project": "Уся площа проєкту" }, "tower": { @@ -134,7 +134,7 @@ "rpm": "Швидкість ротора", "turn": "Один оберт триває", "stopped": "Ротор зупинено", - "tip": "Швидкість кінця лопаті", + "tip": "Швидкість кінчика лопаті", "height": "Найвища точка лопаті", "blade": "Довжина лопаті", "swept": "Ометана площа", @@ -179,7 +179,7 @@ "output": "Потужність станції зараз", "energy": "Вироблено енергії сьогодні", "homes": "Скільки домівок у США це могло б живити", - "capacity": "Коефіцієнт використання потужності за день", + "capacity": "Коефіцієнт використання потужності за цей день", "voltages": "Напруга на кожному етапі", "voltagesValue": "{{generator}} В, {{collector}} кВ, {{grid}} кВ", "annual": "Одна турбіна за рік", @@ -189,7 +189,7 @@ } }, "parts": { - "land": "Ґрунт", + "land": "Земля", "foundation": "Фундамент", "tower": "Башта", "transformer": "Трансформатор", @@ -225,45 +225,45 @@ "sections": { "farm": { "title": "Двадцять сім турбін і один вітряний день", - "p1": "Перед вами вітрова електростанція: 27 турбін серед пологих полів. Це турбіни Vestas V150, і на повній потужності кожна дає 4,2 мегавата (МВт). Мегават дорівнює мільйону ватів, тож разом станція може дати близько 113 МВт. За розміром вона як Моларбер’єт у Швеції, справжня станція з 27 таких самих турбін, що працює з 2021 року.", - "p2": "Турбіни стоять рядами впоперек переважного вітру, тобто напрямку, звідки вітер дме найчастіше. Тут це захід. Інженери міряють відстані в діаметрах ротора, які позначають літерою D, і тут один D дорівнює 150 м. Ряди стоять на відстані 7 D, тобто 1050 м, а турбіни в ряду на відстані 4 D, тобто 600 м. Кожен ряд зсунутий убік, тож жодна турбіна не стоїть просто за турбіною з переднього ряду.", - "p3": "Згори здається, що тут тісно, але більша частина землі так і лишається полями. Забудовано лише близько 0,3 гектара на мегават, і здебільшого це під’їзні дороги, а не основи турбін. Гектар дорівнює квадрату зі стороною 100 м. Електрика тече збірними кабелями до підстанції, а звідти йде в мережу.", - "p4": "Справжня схема турбін у Моларбер’єті не оприлюднена, тож тут ви бачите власне розташування моделі. Ще модель програє по колу вигаданий вітряний день. Це не середній день: у 24 години вміщено тиху ніч, вітряний ранок, день на повній потужності й вечірню бурю. Годинник іде в сотні разів швидше, ніж насправді, але кожен ротор обертається зі своєю справжньою швидкістю. Дороги, кабелі та стрілки вітру намальовані ширшими, ніж є, щоб їх було видно за кілометри, а освітлення не залежить від годинника." + "p1": "Перед вами вітрова електростанція: 27 турбін серед полів на пологих пагорбах. Це турбіни Vestas V150, і на повній потужності кожна дає 4,2 мегавата (МВт). Мегават дорівнює мільйону ватів, тож уся станція може видати близько 113 МВт. Саме стільки має Моларбер’єт у Швеції: справжня станція з 27 таких самих турбін, що працює з 2021 року.", + "p2": "Турбіни стоять рядами впоперек переважного вітру. Так називають напрямок, звідки вітер дме найчастіше, і тут це захід. Відстані інженери міряють у діаметрах ротора й позначають літерою D. Один D дорівнює ширині ротора від кінчика до кінчика лопатей, тут це 150 м. Між рядами 7 D, тобто 1050 м. Між турбінами в одному ряду 4 D, тобто 600 м. Кожен ряд трохи зсунутий убік, тож жодна турбіна не ховається просто за іншою.", + "p3": "Згори здається, що тут тісно, але більша частина землі так і лишається полями. Забудовано лише близько 0,3 гектара на кожен мегават. Гектар дорівнює квадрату зі стороною 100 м. Здебільшого ця забудова припадає на під’їзні дороги, а не на основи турбін. Збірні кабелі забирають електрику від кожної турбіни й несуть її до підстанції, звідки вона йде в мережу.", + "p4": "Справжнє розташування турбін у Моларбер’єті не оприлюднене, тож схема тут власна, придумана для моделі. Вітряний день теж вигаданий і повторюється по колу. Це не середній день: у 24 години вміщено тиху ніч, вітряний ранок, повну потужність по обіді й вечірню бурю. Годинник іде в сотні разів швидше, ніж насправді, але кожен ротор обертається зі своєю справжньою швидкістю. Дороги, кабелі й стрілки вітру намальовані ширшими, ніж у житті, щоб їх було видно за кілометри. Освітлення однакове в будь-яку годину." }, "tower": { "title": "Лопаті, що літають по колу", - "p1": "Зблизька видно, яка величезна навіть одна турбіна. Сталева башта піднімає маточину на 105 м над землею, а ротор, тобто маточина з трьома лопатями, має 150 м у діаметрі. У найвищій точці оберту кінець лопаті сягає приблизно 180 м, це майже вдвічі вище за Біг-Бен. Там угорі вітер дме швидше, ніж біля землі, як показує розділ 5.", - "p2": "Кожна лопать завдовжки близько 74 м, це майже розмах крил Airbus A380, найбільшого пасажирського літака. Її роблять із вуглеволокна й скловолокна, скріплених епоксидною смолою, тобто твердим пластиковим клеєм. Лопать працює не як вітрило, яке штовхає вітер. Вітер обтікає її, як крило літака, і завдяки вигнутій формі виникає підйомна сила, що діє впоперек вітру. Ця сила тягне кожну лопать по колу, і ротор обертається.", - "p3": "На повній швидкості ротор робить лише близько 10 обертів за хвилину, скорочено 10 об/хв. Це один оберт за 6 секунд, тож ротор здається неквапливим. Але кінці лопатей описують величезне коло, тому мчать зі швидкістю близько 80 м/с, майже 300 км/год. Потягніть повзунок вітру, щоб побачити, як ротор розганяється разом із вітром, або виберіть «За ходом дня», щоб вітер знову йшов за годинником. У цій моделі кінці лопатей рухаються у 8 разів швидше за вітер, доки ротор не досягне найвищої швидкості.", - "p4": "Коло, яке описують лопаті, називають ометаною площею: це близько 17 700 м², приблизно 2,5 футбольного поля. Крутити ротор може лише вітер, що проходить крізь це коло. Лінії повітря довкола ротора лише намальовані, бо справжнє повітря невидиме. Трансформатор у цій моделі стоїть біля підніжжя башти, бо де він розташований у V150, не оприлюднено. З тієї ж причини не показано й невеликий нахил ротора." + "p1": "Зблизька видно, яка величезна навіть одна турбіна. Ротор складається з трьох лопатей на круглій маточині й має 150 м у діаметрі. Сталева башта піднімає маточину на 105 м над землею. У верхній точці оберту кінчик лопаті сягає приблизно 180 м, це майже вдвічі вище за Біг-Бен. Там угорі вітер дме швидше, ніж біля землі, і розділ 5 це покаже.", + "p2": "Кожна лопать завдовжки близько 74 м, майже як розмах крил Airbus A380, найбільшого пасажирського літака. Її роблять із вуглеволокна й скловолокна в епоксидній смолі, тобто твердому пластиковому клеї. Вітер не штовхає лопать, як вітрило. Він обтікає її так само, як крило літака. Завдяки вигнутій формі виникає підйомна сила, що діє під прямим кутом до вітру. Ця сила тягне кожну лопать по колу, і ротор обертається.", + "p3": "На повній швидкості ротор робить лише близько 10 обертів за хвилину, скорочено 10 об/хв. Це один оберт за 6 секунд, тож ротор здається ледачим. Але кінчики лопатей описують величезне коло, тому мчать зі швидкістю близько 80 м/с, майже 300 км/год. Потягніть повзунок вітру й подивіться, як ротор розганяється разом із вітром. Виберіть «За ходом дня», і вітром знову керуватиме годинник. У цій моделі кінчики лопатей рухаються у 8 разів швидше за вітер, доки ротор не вийде на найвищу швидкість.", + "p4": "Коло, яке ометають лопаті, називають ометаною площею. Це близько 17 700 м², приблизно 2,5 футбольного поля. Крутити ротор може лише той вітер, що проходить крізь це коло. Лінії повітря довкола ротора лише намальовані, бо справжнє повітря невидиме. Трансформатор турбіни підвищує напругу, перш ніж електрика піде кабелями. Де він стоїть у V150, не оприлюднено, тому модель ставить його біля підніжжя башти. З тієї ж причини не показано й невеликий нахил ротора." }, "nacelle": { "title": "Усередині гондоли", - "p1": "Довга коробка за маточиною називається гондолою, вона завдовжки 12,8 м. Виберіть «Без кожуха», щоб зазирнути всередину. Там розміщені механізми, які перетворюють повільне й потужне обертання ротора на електрику. Усі вони тримаються на опорній рамі, важкій чавунній конструкції, а охолоджувач на даху відводить від них тепло.", - "p2": "У маточині кожна лопать закріплена на підшипнику, тож може провертатися. Три циліндри повороту лопатей, по одному на кожну, провертають їх тиском оливи. Кут, під яким лопать стоїть до вітру, називають кутом повороту, і від нього залежить, скільки вітру вона може вхопити. За маточиною головний вал обертається в головному підшипнику так само повільно, як ротор, близько 10 об/хв.", - "p3": "Генератор має обертатися швидко, тому між валом і генератором стоїть редуктор. У ньому два планетарні ступені, де малі шестерні кружляють довкола центральної, і один косозубий ступінь із похилими зубцями. Разом вони пришвидшують обертання приблизно в 140 разів, і вал на виході розганяється десь до 1485 об/хв. Генератор тут асинхронний, з мідними обмотками й без жодних магнітів. Уся його електрика напругою 800 вольт іде через перетворювач, а той підлаштовує її під мережу.", - "p4": "На валу стоїть дискове гальмо. Але в бурю турбіну зупиняє не воно, а лопаті: вони повертаються до вітру ребром. Гальмо береться до роботи лише тоді, коли ротор уже сповільнився, і тримає його нерухомим. Угорі на башті приводи повороту гондоли розвертають усю гондолу назустріч вітру. У цій моделі вона повертається точно за вітром, бо справжня швидкість повороту V150 не оприлюднена." + "p1": "Коробка за маточиною називається гондолою, вона завдовжки 12,8 м. Виберіть «Без кожуха», щоб зазирнути всередину. Там стоять механізми, які перетворюють повільне й потужне обертання ротора на електрику. Усі вони закріплені на опорній рамі, важкій чавунній основі. Охолоджувач на даху відводить від них тепло.", + "p2": "Кут, під яким лопать стоїть до вітру, називають кутом повороту лопаті. Від нього залежить, скільки вітру лопать може вхопити. Кожна лопать сидить у маточині на підшипнику, тож може провертатися. Три циліндри повороту лопатей, по одному на кожну, провертають їх тиском оливи. За маточиною головний вал обертається в головному підшипнику так само повільно, як ротор, близько 10 об/хв.", + "p3": "Генераторові потрібне швидке обертання, тому між валом і генератором стоїть редуктор. У ньому два планетарні ступені, де малі шестерні кружляють довкола центральної, і один косозубий ступінь із похилими зубцями. Разом вони розкручують вал на виході приблизно в 140 разів швидше, десь до 1485 об/хв. Генератор тут асинхронний: мідні обмотки й жодних магнітів. Він видає електрику напругою 800 вольт, і вся вона проходить через перетворювач. Перетворювач змінює форму струму так, щоб той ішов у такт із мережею.", + "p4": "На валу стоїть дискове гальмо. Але в бурю турбіну зупиняє не воно. Це роблять лопаті: вони повертаються до вітру ребром. Гальмо вмикається, лише коли ротор уже сповільнився, і просто тримає його на місці. Угорі башти приводи повороту гондоли розвертають усю гондолу назустріч вітру. Такий поворот інженери називають рисканням. У цій моделі гондола повертається точно за вітром, бо справжня швидкість повороту V150 не оприлюднена." }, "curve": { "title": "Скільки вітру може вловити турбіна", - "p1": "Поки вітер слабший за 3 м/с, тобто за швидкість увімкнення, ротор чекає. Далі потужність швидко росте, бо потужність вітру залежить від куба швидкості: швидкість, помножена на швидкість і ще раз на швидкість. Удвічі сильніший вітер несе у вісім разів більше потужності. Тож за вітру 4 м/с турбіна дає близько 0,3 МВт, за 8 м/с близько 2,5 МВт, а всі свої 4,2 МВт приблизно від 12 м/с. Потягніть повзунок вітру й подивіться, як точка рухається кривою.", - "p2": "Увесь вітер не вловить жодна турбіна. Щоб забрати енергію, ротор мусить сповільнити повітря, але воно все одно має йти далі, за ротор. Фізик Альберт Бец показав, що навіть ідеальний ротор може забрати щонайбільше 59,3 % потужності. Це і є межа Беца. Ця турбіна в найкращому разі вловлює трохи менше половини, за вітру приблизно від 7 до 8 м/с, і графік показує, наскільки близько вона підходить до межі.", - "p3": "Коли турбіна вже на повній потужності, вітер приносить більше, ніж може прийняти генератор. Тому циліндри повороту трохи відвертають лопаті від вітру, і більша частина його проходить повз. За 15 м/с турбіна бере лише близько 12 % потужності вітру. Кути повороту лопатей для такого сильного вітру в цій моделі придумані, бо справжніх кутів V150 не оприлюднено. Коли вітер перевищує 20 м/с, потужність починає спадати.", - "p4": "За швидкості вимкнення, приблизно від 22 до 25 м/с, лопаті повертаються до вітру ребром. Це флюгування: ротор сповільнюється сам, а потім його тримає дискове гальмо. Знову турбіна запускається лише тоді, коли вітер ослабне приблизно на 2 м/с. У цій моделі всі 27 турбін зупиняються разом за 24,5 м/с і знову запускаються за 22,5 м/с. Зупинка й запуск тривають по 20 хвилин за годинником моделі, довше, ніж насправді, щоб ви встигли їх роздивитися." + "p1": "Ротор чекає, доки вітер не досягне 3 м/с. Це швидкість увімкнення, з якої турбіна починає виробляти електрику. Далі потужність швидко росте. Потужність вітру залежить від куба швидкості: швидкість, помножена на швидкість і ще раз на швидкість. Удвічі сильніший вітер несе у вісім разів більше потужності. Тож за вітру 4 м/с турбіна дає близько 0,3 МВт, за 8 м/с близько 2,5 МВт, а всі свої 4,2 МВт приблизно від 12 м/с. Потягніть повзунок вітру й подивіться, як точка біжить кривою.", + "p2": "Усю цю потужність не вловить жодна турбіна. Щоб забрати енергію, ротор мусить сповільнити повітря, але воно все одно має йти далі, за ротор. Якби ротор зупинив повітря зовсім, нове повітря не змогло б крізь нього пройти. Фізик Альберт Бец показав, що навіть ідеальний ротор може забрати щонайбільше 59,3 % потужності вітру. Це і є межа Беца. Ця турбіна в найкращому разі бере трохи менше половини, за вітру приблизно від 7 до 8 м/с. Графік показує, наскільки близько вона підходить до межі.", + "p3": "Коли вітер дужчий, ніж потрібно для повної потужності, він приносить більше, ніж може прийняти генератор. Тому циліндри повороту трохи відвертають лопаті від вітру, і більша частина вітру проходить повз. За 15 м/с турбіна бере лише близько 12 % потужності вітру. Кути повороту лопатей, які модель показує за такого вітру, вона придумала сама, бо кути V150 не оприлюднено. Коли вітер перевищує 20 м/с, турбіна починає поступово знижувати потужність.", + "p4": "За швидкості вимкнення, приблизно від 22 до 25 м/с, вітер уже надто сильний для роботи турбіни. Лопаті повертаються до вітру ребром. Це флюгування: ротор сповільнюється сам, а потім дискове гальмо тримає його на місці. Знову турбіна запускається лише тоді, коли вітер ослабне приблизно на 2 м/с. У цій моделі всі 27 турбін зупиняються разом за 24,5 м/с і знову запускаються за 22,5 м/с. Зупинка й запуск тривають по 20 хвилин за годинником моделі, довше, ніж у житті, щоб ви встигли їх роздивитися." }, "wakes": { "title": "Чому турбіни стоять так далеко одна від одної", - "p1": "Турбіна забирає з вітру енергію, тож повітря позаду неї рухається повільніше. Цей шлейф повільного, збуреного повітря називають слідом. Увімкніть «Сліди (K)», і за кожним ротором, що обертається, з’явиться шлейф. Сліди найсильніші за помірного вітру, коли ротор найбільше стримує повітря. Коли вітер ще сильніший, лопаті пропускають більше повітря повз себе, і сліди слабнуть.", - "p2": "Ця модель малює сліди за простим і поширеним рецептом, який називають моделлю Єнсена. Кожен слід вона уявляє як конус, що розширюється й поволі знову наповнюється швидшим повітрям. Як швидко він розширюється, задає одне число, тут 0,075, звичне значення для суходолу. За вітру 8 м/с турбіна, що стоїть на 7 D далі за вітром, усе ще отримує повітря, повільніше приблизно на 13 %. Потужність залежить від куба швидкості вітру, тож через це турбіна втрачає приблизно третину потужності.", - "p3": "За рік сліди забирають у станції приблизно від 5 до 15 % енергії. У прикладі від Vestas станція з 24 таких турбін втрачає близько 6 %. Для одного напрямку вітру втрати можуть перевищити 30 %. Тому ряди зазвичай ставлять на відстані від 5 до 9 D уздовж вітру й від 3 до 5 D упоперек. Спробуйте 5, 7 і 9 D: ширші проміжки втрачають менше вітру, але потребують більше землі.", - "p4": "А ще вітер посилюється з висотою, бо земля гальмує повітря біля себе. Увімкніть «Потік повітря (F)», щоб побачити цей профіль: угорі ротора вітер швидший, ніж унизу. Шлейфи й стрілки лише зображують невидиме повітря. У цій моделі земля рівна, зупинений ротор не залишає сліду, а сліди, що перекриваються, додаються за простим правилом." + "p1": "Турбіна забирає з вітру енергію, тож повітря позаду неї рухається повільніше. Такий шлейф повільного, завихреного повітря називають слідом, як слід за човном на воді. Увімкніть «Сліди (K)», і за кожним ротором, що обертається, з’явиться шлейф. Сліди найсильніші за помірного вітру, коли ротор найбільше стримує повітря. Коли вітер дужчий, ніж потрібно для повної потужності, лопаті пропускають більше вітру повз себе, і сліди слабшають.", + "p2": "Модель малює сліди за простим і поширеним рецептом, який називають моделлю Єнсена. Вона уявляє кожен слід конусом, що розширюється й поволі знову наповнюється швидшим повітрям. Як швидко він розширюється, задає одне число: тут 0,075, звичне значення для суходолу. За вітру 8 м/с турбіна, що стоїть на 7 D далі за вітром, усе ще отримує повітря, повільніше приблизно на 13 %. Потужність залежить від куба швидкості, тож через це турбіна втрачає приблизно третину потужності.", + "p3": "За рік сліди забирають у станції приблизно від 5 до 15 % енергії. У прикладі від Vestas, де станція має 24 такі турбіни, втрати становлять близько 6 %. Коли вітер дме з одного невдалого напрямку, втрати можуть перевищити 30 %. Тому ряди зазвичай ставлять на відстані від 5 до 9 D уздовж вітру, а турбіни в ряду від 3 до 5 D упоперек. Спробуйте 5 D, 7 D і 9 D: що ширші проміжки, то менше втрат через сліди, але й землі треба більше.", + "p4": "А ще вітер посилюється з висотою, бо земля гальмує повітря біля себе. Увімкніть «Потік повітря (F)», щоб це побачити: угорі ротора вітер швидший, ніж унизу. Шлейфи й стрілки лише намальовані. У цій моделі земля рівна, зупинений ротор не залишає сліду, а сліди, що перекриваються, додаються за простим правилом." }, "grid": { "title": "Шлях до мережі", - "p1": "Кожен генератор виробляє струм напругою 800 вольт (В). Після перетворювача власний трансформатор турбіни підвищує напругу. За вищої напруги та сама потужність іде меншим струмом, тож менше її втрачається на нагрівання кабелів. Увімкніть «Кабелі (C)», щоб простежити шлях електрики від турбін до мережі.", - "p2": "Збірні кабелі напругою 36 000 вольт, або 36 кіловольт (кВ), з’єднують турбіни з підстанцією. Там інший трансформатор підвищує напругу до 110 кВ для лінії електропередачі. Ці напруги взято з прикладу Vestas для станції в Німеччині.", - "p3": "На повній потужності одна турбіна щогодини виробляє 4,2 мегават-години (МВт·год). Але вітер не завжди сильний, тож за рік турбіна в середньому дає близько 40 % своєї повної потужності. Цю частку називають коефіцієнтом використання потужності. За рік одна турбіна виробляє близько 14 700 МВт·год, і цього вистачає приблизно на 1400 домівок у США. Вигаданий день моделі вітряніший за середній день року, тому коефіцієнт тут виходить вищим.", - "p4": "Виготовлення турбіни теж забирає енергію й дає викиди вуглецю. За весь строк служби на кожну вироблену кіловат-годину (кВт·год) припадає близько 7 г CO2-екв., тобто всіх парникових газів, перерахованих так, ніби це вуглекислий газ. Енергію, витрачену на її виготовлення, турбіна повертає приблизно за 8 місяців. Її розраховано на 20 років, і часто вона служить довше, хоча лопаті досі важко переробляти. Виберіть тиху, звичайну чи вітряну місцевість і побачите, як багато важить звичайний щоденний вітер." + "p1": "Кожен генератор виробляє електрику напругою 800 вольт (В). Після перетворювача власний трансформатор турбіни підвищує напругу. За вищої напруги ту саму потужність можна передати меншим струмом, тож у кабелях менше енергії йде на нагрівання. Увімкніть «Кабелі (C)», щоб простежити шлях електрики від турбін до мережі.", + "p2": "Збірні кабелі напругою 36 000 вольт, або 36 кіловольт (кВ), з’єднують турбіни з підстанцією. Там ще один трансформатор знову підвищує напругу, до 110 кВ, для лінії електропередачі. Ці напруги взято з прикладу Vestas для станції в Німеччині.", + "p3": "На повній потужності одна турбіна щогодини виробляє 4,2 мегават-години (МВт·год). Але вітер не завжди сильний. За рік турбіна в середньому дає близько 40 % своєї повної потужності. Цю частку називають коефіцієнтом використання потужності. За рік одна турбіна виробляє близько 14 700 МВт·год, і цього вистачає приблизно на 1400 домівок у США. Вигаданий день моделі вітряніший за середній, тому коефіцієнт тут виходить вищим.", + "p4": "На виготовлення турбіни теж іде енергія, і це дає викиди вуглецю. За весь строк служби на кожну вироблену кіловат-годину (кВт·год) припадає близько 7 г CO2-екв. У CO2-екв. кожен парниковий газ рахують так, ніби це вуглекислий газ. Енергію, витрачену на виготовлення, турбіна повертає приблизно за 8 місяців. Її розраховано на 20 років, і часто вона працює довше, хоча лопаті досі важко переробляти. Виберіть маловітряну, звичайну чи вітряну місцевість, щоб побачити, наскільки важливий звичайний щоденний вітер." } } } From 14b305a3f2d4a1311c1540d0e5044be0cd1cd375 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:25:14 +0200 Subject: [PATCH 034/132] fix(wind-farm): make the Spanish copy read naturally --- explainers/wind-farm/locales/es.json | 70 ++++++++++++++-------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/explainers/wind-farm/locales/es.json b/explainers/wind-farm/locales/es.json index f032428c..fefba1bf 100644 --- a/explainers/wind-farm/locales/es.json +++ b/explainers/wind-farm/locales/es.json @@ -2,10 +2,10 @@ "meta": { "title": "Cómo funciona un parque eólico", "eyebrow": "Energía renovable", - "tagline": "Veintisiete aerogeneradores y un día de viento: así es como una pala con forma de ala transforma el aire que se mueve en electricidad para la red.", + "tagline": "Veintisiete aerogeneradores y un día de viento: así es como una pala con forma de ala convierte el viento en electricidad para la red.", "description": "Un día de viento en un parque eólico 3D: cómo captan el aire las palas, qué gira en la góndola, por qué van tan separados y cómo llega la energía a la red.", "summary": "La distribución del parque, la torre y las palas, la multiplicadora y el generador, la curva de potencia y el límite de Betz, las estelas y el camino hasta la red.", - "socialAlt": "Un parque eólico sobre colinas suaves, con un aerogenerador en primer plano, junto al título «Cómo funciona un parque eólico»" + "socialAlt": "Un parque eólico sobre un terreno ondulado, con un aerogenerador en primer plano, junto al título «Cómo funciona un parque eólico»" }, "stage": { "label": "Modelo interactivo de un parque eólico", @@ -39,7 +39,7 @@ "siteWind": "Viento de la zona", "siteWindOptions": { "calm": "Zona tranquila", - "typical": "Zona típica", + "typical": "Zona normal", "windy": "Zona ventosa" }, "spacingOptions": { @@ -56,8 +56,8 @@ "cutIn": "Arranque, 3 m/s", "peak": "Mejor aprovechamiento, 8 m/s", "rated": "Plena potencia, 12 m/s", - "rampDown": "Reduciendo potencia, 23 m/s", - "cutOut": "Por encima del corte, 25 m/s" + "rampDown": "Bajando potencia, 23 m/s", + "cutOut": "Pasado el corte, 25 m/s" }, "groups": { "siteWind": "Viento de la zona", @@ -94,11 +94,11 @@ "farm": "Todo el parque", "state": { "idle": "Esperando viento", - "partial": "Carga parcial", + "partial": "Por debajo del máximo", "full": "Plena potencia", - "rampDown": "Reduciendo potencia", + "rampDown": "Bajando potencia", "stopping": "Palas en bandera, parando", - "parked": "Detenido", + "parked": "Parado", "starting": "Arrancando" } }, @@ -148,7 +148,7 @@ "pitch": "Ángulo de paso", "brake": "Freno de disco", "brakeValue": { - "released": "Liberado", + "released": "Suelto", "holding": "Sujetando el rotor" }, "volts": "Voltaje del generador" @@ -171,7 +171,7 @@ "wakes": { "spacing": "Distancia entre filas", "deficit": "Frenado por la estela en la fila siguiente", - "loss": "Pérdida actual del parque por estelas", + "loss": "Potencia que pierde ahora el parque por las estelas", "top": "Viento en lo alto del rotor", "bottom": "Viento en la parte baja del rotor" }, @@ -195,7 +195,7 @@ "transformer": "Transformador", "towerCable": "Cable eléctrico", "nacelle": "Góndola", - "cooler": "Refrigerador", + "cooler": "Enfriador", "bedplate": "Bancada", "hub": "Buje", "blades": "Palas", @@ -225,45 +225,45 @@ "sections": { "farm": { "title": "Veintisiete aerogeneradores y un día de viento", - "p1": "Esto es un parque eólico: 27 aerogeneradores repartidos por campos ondulados. Todos son Vestas V150, y cada uno da 4,2 megavatios (MW) a plena potencia. Un megavatio es un millón de vatios, así que entre todos el parque puede producir unos 113 MW. Tiene el tamaño de Målarberget, en Suecia, un parque real con 27 de estos aerogeneradores que funciona desde 2021.", - "p2": "Los aerogeneradores se colocan en filas de cara al viento dominante, que es la dirección desde la que sopla el viento más a menudo. Aquí viene del oeste. Los ingenieros miden las distancias en diámetros del rotor, que se escriben D, y aquí un D son 150 m. Las filas están a 7 D unas de otras, es decir, a 1050 m, y los aerogeneradores de una misma fila, a 4 D, o 600 m. Cada fila está algo desplazada hacia un lado, así que ningún aerogenerador queda justo detrás de otro de la fila de delante.", - "p3": "Desde arriba parece que están muy juntos, pero casi todo el terreno sigue siendo campo. En realidad solo se construye sobre unas 0,3 hectáreas por megavatio, y la mayor parte son caminos de acceso, no bases de aerogeneradores. Una hectárea es un cuadrado de 100 m de lado. La electricidad viaja por cables colectores hasta una subestación, y desde allí sale hacia la red.", - "p4": "La distribución real de Målarberget no se ha publicado, así que la que ves es propia de este modelo. El modelo también repite en bucle un día de viento inventado. No es un día medio: reúne en 24 horas una noche en calma, una mañana de brisa, una tarde a plena potencia y una tormenta al atardecer. El reloj va cientos de veces más rápido que en la realidad, pero cada rotor gira a su velocidad real. Los caminos, los cables y las flechas del viento se dibujan más anchos de lo que son para que puedas verlos a kilómetros de distancia, y la luz no sigue al reloj." + "p1": "Esto es un parque eólico: 27 aerogeneradores repartidos por campos ondulados. Todos son del modelo Vestas V150, y cada uno da 4,2 megavatios (MW) a plena potencia. Un megavatio es un millón de vatios, así que el parque entero puede producir unos 113 MW. Coincide con Målarberget, en Suecia: un parque real con 27 de estos aerogeneradores que funciona desde 2021.", + "p2": "Los aerogeneradores están en filas puestas de cara al viento dominante, que es la dirección de la que sopla el viento más a menudo. Aquí viene del oeste. Los ingenieros miden las distancias entre aerogeneradores en diámetros de rotor, que se escriben D. Un diámetro es el ancho del rotor de punta a punta: aquí, 150 m. Las filas están separadas 7 D, que son 1050 m. Dentro de cada fila, los aerogeneradores están a 4 D unos de otros, es decir, a 600 m. Cada fila está un poco desplazada hacia un lado, para que ningún aerogenerador quede justo detrás de otro.", + "p3": "Desde arriba parece que están muy juntos, pero casi todo el terreno sigue siendo campo. Solo se construye de verdad sobre unas 0,3 hectáreas por megavatio. Una hectárea es un cuadrado de 100 m de lado. Y la mayor parte de esa superficie son caminos de acceso, no las bases de los aerogeneradores. Los cables colectores recogen la electricidad de cada aerogenerador y la llevan hasta una subestación, donde entra en la red.", + "p4": "La distribución real de Målarberget no se ha publicado, así que la que ves es propia de este modelo. El día de viento también es inventado, y se repite en bucle. No es un día típico: reúne en 24 horas una noche en calma, una mañana de brisa, una tarde a plena potencia y una tormenta al anochecer. El reloj corre cientos de veces más rápido que en la realidad, pero cada rotor gira a su velocidad real. Los caminos, los cables y las flechas del viento se dibujan más anchos de lo que son, para que puedas verlos a kilómetros de distancia. La luz es la misma a cualquier hora." }, "tower": { "title": "Palas que vuelan en círculo", - "p1": "De cerca, un solo aerogenerador es enorme. La torre de acero sube el buje a 105 m del suelo, y el rotor, que es el buje con sus tres palas, mide 150 m de lado a lado. En lo más alto de su giro, la punta de una pala llega a unos 180 m, casi el doble de alto que el Big Ben. Allí arriba el viento sopla más fuerte que cerca del suelo, como verás en el capítulo 5.", - "p2": "Cada pala mide unos 74 m, casi la envergadura de un Airbus A380, el avión de pasajeros más grande. Está hecha de fibra de carbono y fibra de vidrio unidas con epoxi, un pegamento plástico muy duro. Una pala no es una vela que el viento empuja. El viento pasa por ella como por el ala de un avión, y su forma curva crea sustentación, una fuerza que actúa de lado, en ángulo recto con el viento. La sustentación tira de cada pala y hace girar el rotor.", - "p3": "A toda velocidad, el rotor da solo unas 10 vueltas por minuto, o 10 rpm (revoluciones por minuto). Es una vuelta cada 6 segundos, y parece que va sin prisa. Pero las puntas recorren un círculo enorme, así que van a unos 80 m/s, cerca de 300 km/h. Arrastra el control del viento para ver cómo el rotor se acelera con él, o elige Seguir el día para que el viento vuelva a seguir la hora del día. En este modelo, las puntas van 8 veces más rápido que el viento hasta que el rotor llega a su velocidad máxima.", - "p4": "El círculo que recorren las palas es el área barrida: unos 17 700 m², lo que ocupan unos 2,5 campos de fútbol. Solo el viento que cruza ese círculo puede mover el rotor. Las líneas de aire alrededor del rotor son un dibujo, porque el aire de verdad es invisible. En este modelo, el transformador está al pie de la torre, porque no se ha publicado dónde lo lleva el V150. Por la misma razón, no se dibuja la ligera inclinación del rotor." + "p1": "De cerca, un solo aerogenerador es enorme. El rotor está formado por tres palas unidas a un buje redondo, y mide 150 m de punta a punta. La torre de acero levanta el buje a 105 m del suelo. Cuando una pala pasa por lo más alto de su giro, la punta llega a unos 180 m, casi el doble de alto que el Big Ben. Allí arriba el viento sopla más fuerte que cerca del suelo, como verás en el capítulo 5.", + "p2": "Cada pala mide unos 74 m, casi la envergadura de un Airbus A380, el avión de pasajeros más grande del mundo. Está hecha de fibra de carbono y fibra de vidrio unidas con resina epoxi, un pegamento plástico muy duro. El viento no empuja la pala como a una vela. Pasa por encima de ella igual que por el ala de un avión. Su forma curva crea sustentación, una fuerza que actúa en ángulo recto con el viento. La sustentación tira de cada pala, y el rotor gira.", + "p3": "A toda velocidad, el rotor da solo unas 10 vueltas por minuto, o 10 rpm (revoluciones por minuto). Es una vuelta cada 6 segundos, y parece que va sin prisa. Pero las puntas recorren un círculo enorme, así que van a unos 80 m/s, cerca de 300 km/h. Arrastra el control del viento y mira cómo el rotor se acelera con él. Elige Seguir el día para que el viento vuelva a cambiar con la hora. En este modelo, las puntas van 8 veces más rápido que el viento, hasta que el rotor llega a su velocidad máxima.", + "p4": "El círculo que recorren las palas es el área barrida: unos 17 700 m², más o menos lo que ocupan 2,5 campos de fútbol. Solo el viento que pasa por ese círculo puede mover el rotor. Las líneas de aire que rodean el rotor son un dibujo, porque el aire de verdad no se ve. El transformador del aerogenerador sube el voltaje de su electricidad para mandarla por los cables. No se ha publicado dónde lo lleva el V150, así que este modelo lo pone al pie de la torre. Por la misma razón, no se dibuja la ligera inclinación del rotor." }, "nacelle": { "title": "Dentro de la góndola", - "p1": "La caja alargada que hay detrás del buje es la góndola, de 12,8 m de largo. Elige Sin cubierta para ver lo que hay dentro. Allí están las máquinas que convierten el giro lento y potente del rotor en electricidad. Todas se apoyan en la bancada, un pesado bastidor de hierro fundido, y un refrigerador en el techo se lleva su calor.", - "p2": "En el buje, cada pala va montada sobre un rodamiento para poder girar sobre sí misma. Tres cilindros de paso, uno por pala, las hacen girar con presión de aceite. El ángulo de la pala frente al viento es su ángulo de paso, y decide cuánto viento puede atrapar. Detrás del buje, el eje principal gira dentro de su rodamiento principal a la misma velocidad lenta que el rotor, unas 10 rpm.", - "p3": "Un generador necesita girar rápido, así que entre los dos hay una multiplicadora. Tiene dos etapas planetarias, en las que unos engranajes pequeños giran alrededor de uno central, y una etapa helicoidal, con dientes inclinados. Juntas hacen que el eje gire unas 140 veces más rápido, hasta unas 1485 rpm. El generador es de inducción, con bobinados de cobre y sin imanes. Toda su electricidad, a 800 voltios, pasa por el convertidor, que le da la forma que necesita la red.", - "p4": "En el eje hay un freno de disco. No es lo que detiene el aerogenerador en una tormenta: eso lo hacen las palas, que se ponen de canto al viento. El freno solo sujeta el rotor cuando ya gira despacio, para dejarlo quieto. En lo alto de la torre, los motores de orientación giran toda la góndola para que mire al viento. En este modelo la góndola sigue exactamente la dirección del viento, porque no se ha publicado a qué velocidad gira la del V150." + "p1": "La caja que hay detrás del buje es la góndola, de 12,8 m de largo. Elige Sin cubierta para ver lo que hay dentro. Allí están las máquinas que convierten el giro lento y potente del rotor en electricidad. Todas se apoyan en la bancada, un pesado bastidor de hierro fundido. Un enfriador en el techo se lleva su calor.", + "p2": "La inclinación de una pala frente al viento se llama ángulo de paso. De él depende cuánto viento puede atrapar la pala. Cada pala va montada en el buje sobre un rodamiento, para poder girar sobre sí misma. Tres cilindros de paso, uno por pala, las giran con presión de aceite. Detrás del buje, el eje principal gira dentro de su rodamiento principal a la misma velocidad lenta que el rotor, unas 10 rpm.", + "p3": "El generador necesita girar deprisa, así que entre los dos hay una caja de engranajes, la multiplicadora. Tiene dos etapas planetarias, en las que unos engranajes pequeños giran alrededor de uno central, y una etapa helicoidal, con dientes inclinados. Entre todas hacen que el eje de salida gire unas 140 veces más rápido, hasta unas 1485 rpm. El generador es de inducción: bobinados de cobre, sin imanes. Su electricidad sale a 800 voltios y pasa entera por el convertidor. El convertidor la ajusta para que vaya al compás de la red.", + "p4": "En el eje hay un freno de disco. No es lo que detiene el aerogenerador en una tormenta: eso lo hacen las palas, al ponerse de canto al viento. El freno solo sujeta el rotor cuando ya va despacio, para dejarlo parado. En lo alto de la torre, los motores de orientación giran toda la góndola para ponerla de cara al viento. A este giro se le llama orientación. En este modelo, la góndola sigue el viento a la perfección, porque no se ha publicado a qué velocidad gira la del V150." }, "curve": { "title": "Cuánto viento puede captar un aerogenerador", - "p1": "Por debajo de 3 m/s, la velocidad de arranque, el rotor espera. Por encima, la potencia sube deprisa, porque la potencia del viento crece con el cubo de la velocidad: velocidad por velocidad por velocidad. Si el viento se duplica, lleva ocho veces más potencia. Por eso el aerogenerador da unos 0,3 MW a 4 m/s, unos 2,5 MW a 8 m/s y sus 4,2 MW completos a partir de unos 12 m/s. Arrastra el control del viento y mira cómo avanza el punto por la curva.", - "p2": "Ningún aerogenerador puede captarla toda. Para sacar energía, el rotor tiene que frenar el aire, pero ese aire tiene que poder salir por detrás. El físico Albert Betz demostró que ni siquiera un rotor perfecto puede llevarse más del 59,3 % de la potencia. Es el límite de Betz. Lo mejor que consigue este aerogenerador es algo menos de la mitad, con vientos de unos 7 a 8 m/s, y la gráfica muestra lo cerca que se queda.", - "p3": "Por encima de la plena potencia, el viento trae más de lo que el generador puede aprovechar. Por eso los cilindros de paso giran las palas para sacarlas un poco del viento, y la mayor parte del aire pasa de largo. A 15 m/s, el aerogenerador se queda solo con cerca del 12 % de la potencia del viento. Los ángulos de paso que ves por encima de la plena potencia son propios de este modelo, porque los del V150 no se han publicado. A partir de 20 m/s, la potencia empieza a bajar.", - "p4": "A la velocidad de corte, entre unos 22 y 25 m/s, las palas se ponen de canto al viento. Es la puesta en bandera: el rotor se frena solo y después el freno de disco lo deja quieto. El aerogenerador no vuelve a arrancar hasta que el viento ha bajado unos 2 m/s. En este modelo, los 27 aerogeneradores se paran a la vez a 24,5 m/s y vuelven a arrancar a 22,5 m/s. La parada y el nuevo arranque duran cada uno 20 minutos en el reloj, más que en la realidad, para que puedas verlos." + "p1": "El rotor espera a que el viento llegue a 3 m/s, la velocidad de arranque. A partir de ahí, la potencia sube deprisa. La potencia del viento crece con el cubo de la velocidad: velocidad por velocidad por velocidad. Si el viento sopla el doble de rápido, lleva ocho veces más potencia. Por eso el aerogenerador da unos 0,3 MW a 4 m/s, unos 2,5 MW a 8 m/s y sus 4,2 MW completos a partir de unos 12 m/s. Arrastra el control del viento y mira cómo se mueve el punto por la curva.", + "p2": "Ningún aerogenerador puede quedarse con toda esa potencia. Para sacar energía, el rotor tiene que frenar el aire, pero ese aire tiene que poder salir por detrás. Si el rotor lo parara del todo, no podría entrar aire nuevo. El físico Albert Betz demostró que ni siquiera un rotor perfecto puede sacar más del 59,3 % de la potencia del viento. Es el límite de Betz. Lo mejor que logra este aerogenerador es algo menos de la mitad, con vientos de unos 7 a 8 m/s. La gráfica muestra cuánto se acerca.", + "p3": "Por encima de la plena potencia, el viento trae más de lo que el generador puede aguantar. Entonces los cilindros de paso giran las palas para sacarlas un poco del viento, y la mayor parte del aire pasa de largo. A 15 m/s, el aerogenerador solo se queda con un 12 % de la potencia del viento, más o menos. Los ángulos de paso que ves por encima de la plena potencia son propios de este modelo, porque los del V150 no se han publicado. Por encima de 20 m/s, el aerogenerador empieza a bajar su potencia poco a poco.", + "p4": "A la velocidad de corte, de unos 22 a 25 m/s, el viento es demasiado fuerte para seguir trabajando. Las palas se ponen de canto al viento. Es la puesta en bandera: el rotor se frena solo, y luego el freno de disco lo deja parado. El aerogenerador no vuelve a arrancar hasta que el viento baja unos 2 m/s. En este modelo, los 27 aerogeneradores se paran a la vez a 24,5 m/s y vuelven a arrancar a 22,5 m/s. Tanto la parada como el arranque duran 20 minutos de reloj, más que en la realidad, para que puedas verlos." }, "wakes": { "title": "Por qué los aerogeneradores están tan separados", - "p1": "Un aerogenerador le quita energía al viento, así que el aire que deja detrás va más lento. Esa cola de aire lento y revuelto se llama estela. Activa Estelas (K) para ver un penacho detrás de cada rotor que gira. Las estelas son más fuertes con viento moderado, que es cuando el rotor frena más el aire. Por encima de la plena potencia, las palas dejan pasar más viento y las estelas se desvanecen.", - "p2": "Este modelo dibuja las estelas con una receta sencilla y muy usada, el modelo de Jensen. Trata cada estela como un cono que se va ensanchando y poco a poco se vuelve a llenar de aire más rápido. Lo rápido que se ensancha depende de un solo número, aquí 0,075, el valor habitual en tierra. A 8 m/s, un aerogenerador que está 7 D por detrás, siguiendo el viento, todavía recibe aire alrededor de un 13 % más lento. Como la potencia depende del cubo de la velocidad del viento, eso le cuesta cerca de un tercio de su potencia.", - "p3": "A lo largo de un año, las estelas le cuestan a un parque entre un 5 y un 15 % de su energía, más o menos. Un parque de ejemplo de Vestas, con 24 de estos aerogeneradores, pierde cerca de un 6 %. Para una sola dirección del viento, la pérdida puede pasar del 30 %. Por eso las filas suelen estar separadas de 5 a 9 D en la dirección del viento y de 3 a 5 D a lo ancho. Prueba 5, 7 y 9 D: con más separación se pierde menos viento, pero hace falta más terreno.", - "p4": "El viento también aumenta con la altura, porque el suelo frena el aire que tiene cerca. Activa Flujo de aire (F) para ver el perfil: la parte alta del rotor recibe un viento más rápido que la baja. Los penachos y las flechas son dibujos de un aire que no se ve. En este modelo el suelo es llano, un rotor parado no deja estela y las estelas que se superponen se suman de forma sencilla." + "p1": "Un aerogenerador le quita energía al viento, así que el aire que deja detrás va más lento. Esa cola de aire lento y revuelto se llama estela, como la que deja un barco al pasar. Activa Estelas (K) para ver un penacho detrás de cada rotor que gira. Las estelas son más fuertes con viento moderado, que es cuando el rotor frena más el aire. Por encima de la plena potencia, las palas dejan pasar más viento y las estelas se desvanecen.", + "p2": "Este modelo dibuja las estelas con una receta sencilla y muy usada, el modelo de Jensen. Trata cada estela como un cono que se va ensanchando y que poco a poco se vuelve a llenar de aire más rápido. Lo rápido que se ensancha depende de un solo número: aquí 0,075, el valor habitual en tierra. A 8 m/s, un aerogenerador situado 7 D más atrás, en la dirección del viento, sigue recibiendo aire alrededor de un 13 % más lento. Como la potencia depende del cubo de la velocidad, eso le cuesta cerca de un tercio de su potencia.", + "p3": "A lo largo de un año, las estelas le cuestan a un parque entre un 5 y un 15 % de su energía, más o menos. En un parque de ejemplo de Vestas, con 24 de estos aerogeneradores, la pérdida ronda el 6 %. Si el viento sopla de una dirección poco afortunada, la pérdida puede superar el 30 %. Por eso las filas suelen separarse de 5 a 9 D en la dirección del viento, y los aerogeneradores, de 3 a 5 D a lo ancho. Prueba 5 D, 7 D y 9 D: cuanto más separados, menos se pierde por las estelas, pero más terreno hace falta.", + "p4": "El viento también es más fuerte cuanto más alto, porque el suelo frena el aire que tiene cerca. Activa Flujo de aire (F) para verlo: la parte alta del rotor recibe un viento más rápido que la baja. Los penachos y las flechas son solo dibujos. En este modelo el suelo es llano, un rotor parado no deja estela y las estelas que se solapan se suman de forma sencilla." }, "grid": { "title": "El camino hasta la red", - "p1": "Cada generador produce electricidad a 800 voltios (V). Después del convertidor, el transformador del propio aerogenerador sube el voltaje. Con más voltaje, la misma potencia viaja con menos corriente, así que se pierde menos en forma de calor en los cables. Activa Cables (C) para seguir la electricidad desde los aerogeneradores hasta la red.", - "p2": "Los cables colectores, a 36 000 voltios, o 36 kilovoltios (kV), unen los aerogeneradores con la subestación. Allí otro transformador sube el voltaje a 110 kV para la línea de la red. Estos voltajes son los de un parque de ejemplo de Vestas en Alemania.", - "p3": "A plena potencia, un aerogenerador produce 4,2 megavatios hora (MWh) cada hora. Pero el viento no siempre es fuerte, así que a lo largo del año un aerogenerador da de media cerca del 40 % de su plena potencia. Esa proporción es el factor de capacidad. En un año, un aerogenerador produce unos 14 700 MWh, suficiente para unos 1400 hogares de EE. UU. El día inventado del modelo tiene más viento que la media del año, así que su factor de capacidad sale más alto.", - "p4": "Fabricar un aerogenerador también cuesta energía y carbono: a lo largo de su vida, suma unos 7 g de CO2e por cada kilovatio hora (kWh) que produce. El CO2e cuenta todos los gases de efecto invernadero como si fueran dióxido de carbono. El aerogenerador recupera en unos 8 meses la energía que costó construirlo. Está diseñado para 20 años y a menudo funciona más tiempo, aunque sus palas siguen siendo difíciles de reciclar. Elige una zona tranquila, típica o ventosa para ver cuánto importa el viento de cada día." + "p1": "Cada generador produce electricidad a 800 voltios (V). Después del convertidor, el transformador del propio aerogenerador sube el voltaje. Con más voltaje, la misma potencia necesita menos corriente, así que los cables pierden menos en forma de calor. Activa Cables (C) para seguir la electricidad desde los aerogeneradores hasta la red.", + "p2": "Los cables colectores, a 36 000 voltios, o 36 kilovoltios (kV), unen los aerogeneradores con la subestación. Allí otro transformador vuelve a subir el voltaje, hasta 110 kV, para la línea de la red. Estos voltajes son los de un parque de ejemplo de Vestas en Alemania.", + "p3": "A plena potencia, un aerogenerador produce 4,2 megavatios hora (MWh) cada hora. Pero el viento no siempre sopla fuerte. A lo largo del año, un aerogenerador da de media cerca del 40 % de su plena potencia. Esa proporción es su factor de capacidad. En un año, un aerogenerador produce unos 14 700 MWh, suficiente para unos 1400 hogares de Estados Unidos. El día inventado del modelo tiene más viento que un día medio, así que su factor de capacidad sale más alto.", + "p4": "Fabricar un aerogenerador también cuesta energía y carbono. A lo largo de toda su vida, genera unos 7 g de CO2e por cada kilovatio hora (kWh) que produce. El CO2e cuenta todos los gases de efecto invernadero como si fueran dióxido de carbono. Un aerogenerador recupera en unos 8 meses la energía que costó construirlo. Está diseñado para durar 20 años y a menudo funciona más tiempo, aunque sus palas todavía son difíciles de reciclar. Prueba una zona tranquila, normal o ventosa para ver cuánto importa el viento de cada día." } } } From f543d814ec95ee2c184b5209d87e2a023fc604a4 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:25:26 +0200 Subject: [PATCH 035/132] fix(wind-farm): make the German copy read naturally --- explainers/wind-farm/locales/de.json | 70 ++++++++++++++-------------- 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/explainers/wind-farm/locales/de.json b/explainers/wind-farm/locales/de.json index 5cad0549..c1df2987 100644 --- a/explainers/wind-farm/locales/de.json +++ b/explainers/wind-farm/locales/de.json @@ -2,14 +2,14 @@ "meta": { "title": "So funktioniert ein Windpark", "eyebrow": "Erneuerbare Energie", - "tagline": "Siebenundzwanzig Windräder und ein windiger Tag: So macht ein Rotorblatt in Flügelform aus bewegter Luft Strom für das Netz.", - "description": "Ein 3D-Windpark an einem windigen Tag: wie Rotorblätter Wind fangen, was in der Gondel rotiert, warum Windräder Abstand halten, wie Strom ins Netz kommt.", - "summary": "Die Anordnung des Parks, Turm und Rotorblätter, Getriebe und Generator, die Leistungskurve und die Betz-Grenze, der Nachlauf und der Weg ins Netz.", - "socialAlt": "Ein Windpark in hügeliger Landschaft mit einem Windrad ganz nah im Vordergrund, daneben der Titel „So funktioniert ein Windpark“" + "tagline": "Siebenundzwanzig Windräder, ein windiger Tag: So macht ein Blatt in Flügelform aus bewegter Luft Strom fürs Netz.", + "description": "Ein 3D-Windpark, ein windiger Tag: wie Blätter den Wind nutzen, was sich in der Gondel dreht, warum Windräder Abstand halten und wie Strom ins Netz kommt.", + "summary": "Die Anordnung des Parks, Turm und Rotorblätter, Getriebe und Generator, Leistungskurve und Betz-Grenze, Nachlauf und der Weg ins Netz.", + "socialAlt": "Ein Windpark in hügeliger Landschaft, vorn ein Windrad ganz nah, daneben der Titel „So funktioniert ein Windpark“" }, "stage": { "label": "Interaktives Modell eines Windparks", - "gauge": "Live-Werte für Wind und Leistung" + "gauge": "Wind und Leistung live" }, "timeline": { "label": "Tageszeit", @@ -99,7 +99,7 @@ "rampDown": "Regelt ab", "stopping": "Fährt in Fahnenstellung", "parked": "Geparkt", - "starting": "Fährt an" + "starting": "Läuft an" } }, "units": { @@ -155,14 +155,14 @@ }, "curve": { "wind": "Windgeschwindigkeit", - "state": "Zustand der Anlage", - "power": "Leistung der Anlage", - "caption": "Leistung im Wind, Betz-Grenze und Leistung dieser Anlage bei Wind von 0 bis 30 m/s", + "state": "Zustand des Windrads", + "power": "Leistung des Windrads", + "caption": "Leistung im Wind, Betz-Grenze und Leistung dieses Windrads bei Wind von 0 bis 30 m/s", "axisWind": "Windgeschwindigkeit (m/s)", "axisPower": "Leistung (MW)", "lineWind": "Leistung im Wind", "lineBetz": "Betz-Grenze", - "lineTurbine": "Diese Anlage", + "lineTurbine": "Dieses Windrad", "windPower": "Leistung im Wind", "cp": "Genutzter Anteil", "betz": "Anteil an der Betz-Grenze", @@ -170,7 +170,7 @@ }, "wakes": { "spacing": "Abstand der Reihen", - "deficit": "Verlangsamung an der nächsten Reihe", + "deficit": "Windabschwächung an der nächsten Reihe", "loss": "Aktueller Verlust durch Nachlauf", "top": "Wind oben am Rotor", "bottom": "Wind unten am Rotor" @@ -225,45 +225,45 @@ "sections": { "farm": { "title": "Siebenundzwanzig Windräder und ein windiger Tag", - "p1": "Das ist ein Windpark: 27 Windräder, verteilt über sanft hügelige Felder. Alle sind vom Typ Vestas V150, und jedes leistet bei voller Last 4,2 Megawatt (MW). Ein Megawatt sind eine Million Watt, zusammen schafft der Park also etwa 113 MW. Er ist so groß wie Målarberget in Schweden, ein echter Park mit 27 dieser Anlagen, der seit 2021 läuft.", - "p2": "Die Windräder stehen in Reihen quer zur Hauptwindrichtung, also der Richtung, aus der der Wind am häufigsten weht. Hier kommt er aus Westen. Ingenieure messen die Abstände in Rotordurchmessern, kurz D, und ein D sind hier 150 m. Die Reihen liegen 7 D auseinander, also 1.050 m, die Windräder in einer Reihe 4 D, also 600 m. Jede Reihe ist seitlich versetzt, damit kein Windrad direkt hinter einem aus der Reihe davor steht.", - "p3": "Von oben wirkt der Park dicht gedrängt, doch der größte Teil der Fläche ist weiter Ackerland. Nur etwa 0,3 Hektar pro Megawatt sind wirklich bebaut, und das sind vor allem Zufahrtswege, nicht die Sockel der Windräder. Ein Hektar ist ein Quadrat mit 100 m Seitenlänge. Der Strom fließt durch Sammelkabel zu einem Umspannwerk und von dort weiter ins Netz.", - "p4": "Wie Målarberget wirklich angeordnet ist, wurde nicht veröffentlicht, deshalb zeigt das Modell eine eigene Anordnung. Außerdem spielt es in einer Schleife einen erfundenen Windtag ab. Das ist kein Durchschnittstag: Er packt eine ruhige Nacht, einen windigen Morgen, einen Nachmittag mit voller Leistung und einen Sturm am Abend in 24 Stunden. Die Uhr läuft Hunderte Male schneller als in Wirklichkeit, aber jeder Rotor dreht sich in seinem echten Tempo. Wege, Kabel und Windpfeile sind breiter gezeichnet, als sie sind, damit Sie sie auch aus Kilometern Entfernung sehen, und das Licht ändert sich nicht mit der Uhrzeit." + "p1": "Das ist ein Windpark: 27 Windräder auf sanft hügeligen Feldern. Alle sind vom Typ Vestas V150. Bei Volllast, also mit voller Kraft, leistet jedes 4,2 Megawatt (MW). Ein Megawatt sind eine Million Watt. Der ganze Park schafft also etwa 113 MW. Das entspricht dem Park Målarberget in Schweden. Dort laufen seit 2021 genau 27 dieser Windräder.", + "p2": "Die Windräder stehen in Reihen quer zur Hauptwindrichtung. Aus dieser Richtung weht der Wind am häufigsten, hier aus Westen. Fachleute messen die Abstände in Rotordurchmessern, kurz D. Ein D ist die Breite des Rotors von Blattspitze zu Blattspitze, hier 150 m. Die Reihen stehen 7 D auseinander, also 1.050 m. In einer Reihe sind es 4 D, also 600 m. Jede Reihe ist ein Stück zur Seite versetzt. So steht kein Windrad direkt hinter einem anderen.", + "p3": "Von oben wirkt alles dicht gedrängt, doch der größte Teil der Fläche ist weiter Acker. Nur etwa 0,3 Hektar pro Megawatt sind wirklich bebaut. Ein Hektar ist ein Quadrat mit 100 m Seitenlänge. Bebaut sind vor allem Zufahrtswege, nicht die Sockel der Windräder. Sammelkabel holen den Strom bei jedem Windrad ab und bringen ihn zu einem Umspannwerk. Dort geht er ins Netz.", + "p4": "Der echte Lageplan von Målarberget ist nicht veröffentlicht. Die Anordnung hier hat sich das Modell deshalb selbst ausgedacht. Auch der Windtag ist erfunden und läuft in einer Schleife. Er ist kein Durchschnittstag: In 24 Stunden stecken eine ruhige Nacht, eine Brise am Morgen, ein Nachmittag unter Volllast und ein Sturm am Abend. Die Uhr läuft Hunderte Male schneller als in Wirklichkeit. Trotzdem dreht sich jeder Rotor in seinem echten Tempo. Wege, Kabel und Windpfeile sind breiter gezeichnet, als sie sind. So sehen Sie sie auch aus Kilometern Entfernung. Das Licht bleibt zu jeder Stunde gleich." }, "tower": { "title": "Blätter, die im Kreis fliegen", - "p1": "Aus der Nähe ist ein einzelnes Windrad riesig. Der Turm aus Stahl hebt die Nabe 105 m über den Boden, und der Rotor, also die Nabe mit ihren drei Blättern, misst 150 m im Durchmesser. Steht ein Blatt ganz oben, reicht seine Spitze etwa 180 m hoch, fast doppelt so hoch wie Big Ben. Dort oben weht der Wind schneller als in Bodennähe, wie Kapitel 5 zeigt.", - "p2": "Jedes Rotorblatt ist etwa 74 m lang, fast so lang wie die Spannweite eines Airbus A380, des größten Passagierflugzeugs. Es besteht aus Kohle- und Glasfasern in Epoxidharz, einem harten Kunststoffkleber. Ein Blatt ist kein Segel, das der Wind vor sich herschiebt. Die Luft strömt darüber wie über den Flügel eines Flugzeugs, und die gewölbte Form erzeugt Auftrieb, eine Kraft quer zum Wind. Der Auftrieb zieht jedes Blatt herum, und der Rotor dreht sich.", - "p3": "Bei voller Drehzahl schafft der Rotor nur etwa 10 Umdrehungen pro Minute, kurz 10 U/min. Das ist eine Umdrehung alle 6 Sekunden und sieht gemächlich aus. Doch die Spitzen ziehen einen riesigen Kreis und sind darum etwa 80 m/s schnell, knapp 300 km/h. Schieben Sie den Windregler, dann dreht der Rotor mit dem Wind schneller, oder wählen Sie „Tagesverlauf“, dann gibt wieder die Uhr den Wind vor. In diesem Modell sind die Spitzen 8-mal so schnell wie der Wind, bis der Rotor seine Höchstdrehzahl erreicht.", - "p4": "Der Kreis, den die Blätter überstreichen, ist die Rotorfläche: etwa 17.700 m², so groß wie rund 2,5 Fußballfelder. Nur der Wind, der durch diesen Kreis strömt, kann den Rotor drehen. Die Strömungslinien rund um den Rotor sind nur gezeichnet, denn echte Luft ist unsichtbar. Der Transformator steht in diesem Modell am Fuß des Turms, weil nicht veröffentlicht ist, wo die V150 ihn unterbringt. Aus demselben Grund fehlt die leichte Neigung des Rotors." + "p1": "Aus der Nähe ist ein einzelnes Windrad riesig. Der Rotor besteht aus drei Blättern an einer runden Nabe und misst 150 m im Durchmesser. Der Turm aus Stahl hebt die Nabe 105 m über den Boden. Steht ein Blatt ganz oben, reicht seine Spitze etwa 180 m hoch. Das ist fast doppelt so hoch wie Big Ben. Dort oben weht der Wind schneller als am Boden, wie Kapitel 5 zeigt.", + "p2": "Jedes Rotorblatt ist etwa 74 m lang. Das ist fast die Spannweite eines Airbus A380, des größten Passagierflugzeugs. Es besteht aus Kohle- und Glasfasern in Epoxidharz, einem harten Kunststoffkleber. Ein Blatt ist kein Segel, das der Wind vor sich herschiebt. Die Luft strömt darüber wie über den Flügel eines Flugzeugs. Die gewölbte Form erzeugt dabei Auftrieb, eine Kraft quer zum Wind. Der Auftrieb zieht jedes Blatt herum, und der Rotor dreht sich.", + "p3": "Mit voller Drehzahl schafft der Rotor nur etwa 10 Umdrehungen pro Minute, kurz 10 U/min. Das ist eine Umdrehung alle 6 Sekunden und wirkt gemächlich. Doch die Spitzen laufen auf einem riesigen Kreis. Deshalb sind sie etwa 80 m/s schnell, knapp 300 km/h. Schieben Sie den Windregler, dann dreht der Rotor mit dem Wind schneller. Wählen Sie „Tagesverlauf“, dann bestimmt wieder die Uhr den Wind. In diesem Modell sind die Spitzen 8-mal so schnell wie der Wind, bis der Rotor seine Höchstdrehzahl erreicht.", + "p4": "Beim Drehen beschreiben die Blätter einen Kreis, die Rotorfläche: etwa 17.700 m², so viel wie rund 2,5 Fußballfelder. Nur Wind, der durch diesen Kreis strömt, kann den Rotor antreiben. Die Luftlinien rund um den Rotor sind nur gezeichnet, denn echte Luft ist unsichtbar. Der Transformator des Windrads erhöht die Spannung seines Stroms für die Kabel. Wo die V150 ihn unterbringt, ist nicht veröffentlicht. Darum steht er in diesem Modell am Fuß des Turms. Aus demselben Grund zeichnet das Modell den Rotor ohne seine leichte Neigung." }, "nacelle": { "title": "Ein Blick in die Gondel", - "p1": "Der lange Kasten hinter der Nabe ist die Gondel, 12,8 m lang. Wählen Sie „Ohne Haube“, um hineinzusehen. Darin stecken die Maschinen, die aus der langsamen, kraftvollen Drehung des Rotors Strom machen. Sie stehen alle auf dem Maschinenträger, einem schweren Rahmen aus Gusseisen, und ein Kühler auf dem Dach führt ihre Wärme ab.", - "p2": "In der Nabe sitzt jedes Blatt auf einem Lager, damit es sich drehen lässt. Drei Pitchzylinder, einer pro Blatt, verstellen die Blätter mit Öldruck. Den Winkel des Blattes zum Wind nennt man Pitch oder Blattwinkel, und er bestimmt, wie viel der Wind zu fassen bekommt. Hinter der Nabe dreht sich die Hauptwelle in ihrem Hauptlager, genauso langsam wie der Rotor, mit etwa 10 U/min.", - "p3": "Ein Generator muss sich schnell drehen, deshalb sitzt dazwischen ein Getriebe. Es hat zwei Planetenstufen, in denen kleine Zahnräder um ein mittleres kreisen, und eine Stirnradstufe mit schrägen Zähnen. Zusammen machen sie die Welle etwa 140-mal schneller, bis auf etwa 1.485 U/min. Der Generator ist ein Asynchrongenerator mit Kupferwicklungen und ohne Magnete. Seine gesamte Leistung fließt mit 800 Volt durch den Umrichter, der sie passend fürs Netz umformt.", - "p4": "Auf der Welle sitzt eine Scheibenbremse. Doch nicht sie stoppt das Windrad im Sturm, sondern die Blätter: Sie drehen ihre Kante in den Wind. Die Bremse hält den Rotor erst fest, wenn er schon langsam ist, und parkt ihn so. Oben auf dem Turm drehen Azimutantriebe die ganze Gondel in den Wind. In diesem Modell folgt sie dem Wind exakt, weil nicht veröffentlicht ist, wie schnell die echte V150 ihre Gondel dreht." + "p1": "Der Kasten hinter der Nabe ist die Gondel, 12,8 m lang. Wählen Sie „Ohne Haube“, um hineinzusehen. Darin stecken die Maschinen, die aus der langsamen, kräftigen Drehung des Rotors Strom machen. Sie stehen alle auf dem Maschinenträger, einem schweren Rahmen aus Gusseisen. Ein Kühler auf dem Dach führt ihre Wärme ab.", + "p2": "Der Winkel eines Blatts zum Wind heißt Pitch oder Blattwinkel. Davon hängt ab, wie viel Wind das Blatt zu fassen bekommt. Jedes Blatt sitzt in der Nabe auf einem Lager, damit es sich verstellen lässt. Drei Pitchzylinder, einer pro Blatt, drehen die Blätter mit Öldruck. Hinter der Nabe dreht sich die Hauptwelle in ihrem Hauptlager. Sie läuft so langsam wie der Rotor, mit etwa 10 U/min.", + "p3": "Der Generator muss schnell drehen, deshalb sitzt ein Getriebe dazwischen. Es hat zwei Planetenstufen, in denen kleine Zahnräder um ein mittleres kreisen. Dazu kommt eine Stirnradstufe mit schrägen Zähnen. Zusammen machen sie die Drehung etwa 140-mal schneller, bis etwa 1.485 U/min. Der Generator ist ein Asynchrongenerator: Kupferwicklungen, keine Magnete. Sein Strom kommt mit 800 Volt heraus und fließt komplett durch den Umrichter. Der Umrichter formt den Strom so um, dass er im Takt des Netzes bleibt.", + "p4": "Auf der Welle sitzt eine Scheibenbremse. Im Sturm stoppt aber nicht sie das Windrad. Das tun die Blätter, indem sie ihre Kante in den Wind drehen. Die Bremse hält den Rotor nur fest, wenn er schon langsam geworden ist. So parkt sie ihn. Oben auf dem Turm drehen Azimutantriebe die ganze Gondel in den Wind. Diese Drehung heißt Azimutverstellung oder Windnachführung. In diesem Modell folgt die Gondel dem Wind ganz genau. Wie schnell sich die echte V150 dreht, ist nämlich nicht veröffentlicht." }, "curve": { "title": "Wie viel Wind ein Windrad einfangen kann", - "p1": "Unter 3 m/s, der Einschaltgeschwindigkeit, wartet der Rotor. Darüber steigt die Leistung schnell, denn die Leistung im Wind wächst mit der dritten Potenz der Geschwindigkeit: Geschwindigkeit mal Geschwindigkeit mal Geschwindigkeit. Weht der Wind doppelt so schnell, trägt er achtmal so viel Leistung. Deshalb liefert das Windrad bei 4 m/s etwa 0,3 MW, bei 8 m/s etwa 2,5 MW und ab etwa 12 m/s seine vollen 4,2 MW. Schieben Sie den Windregler und sehen Sie zu, wie der Punkt die Kurve entlangwandert.", - "p2": "Kein Windrad kann die ganze Leistung einfangen. Um Energie zu entnehmen, muss der Rotor die Luft bremsen, doch hinter ihm muss sie noch abfließen können. Der Physiker Albert Betz zeigte, dass selbst ein perfekter Rotor höchstens 59,3 % der Leistung nutzen kann. Das ist die Betz-Grenze. Dieses Windrad schafft im besten Fall knapp die Hälfte, bei etwa 7 bis 8 m/s, und das Diagramm zeigt, wie nah es der Grenze kommt.", - "p3": "Ist die volle Leistung erreicht, bringt der Wind mehr Leistung, als der Generator aufnehmen kann. Deshalb drehen die Pitchzylinder die Blätter ein Stück aus dem Wind, und der größte Teil des Windes strömt vorbei. Bei 15 m/s nutzt das Windrad nur etwa 12 % der Leistung im Wind. Die Blattwinkel oberhalb der vollen Leistung hat das Modell selbst festgelegt, weil die der V150 nicht veröffentlicht sind. Über 20 m/s beginnt die Anlage, ihre Leistung zurückzufahren.", - "p4": "Bei der Abschaltgeschwindigkeit, etwa 22 bis 25 m/s, drehen sich die Blätter mit der Kante in den Wind. Das nennt man Fahnenstellung: Der Rotor wird von selbst langsamer, dann parkt ihn die Scheibenbremse. Das Windrad startet erst wieder, wenn der Wind um etwa 2 m/s nachgelassen hat. In diesem Modell stoppen alle 27 Windräder gemeinsam bei 24,5 m/s und starten bei 22,5 m/s neu. Stopp und Neustart dauern auf der Uhr jeweils 20 Minuten, länger als in Wirklichkeit, damit Sie zusehen können." + "p1": "Der Rotor wartet, bis der Wind 3 m/s erreicht. Das ist die Einschaltgeschwindigkeit. Danach steigt die Leistung schnell. Der Grund: Die Leistung im Wind wächst mit der dritten Potenz der Geschwindigkeit. Man rechnet also Geschwindigkeit mal Geschwindigkeit mal Geschwindigkeit. Weht der Wind doppelt so schnell, trägt er achtmal so viel Leistung. Deshalb liefert das Windrad bei 4 m/s etwa 0,3 MW, bei 8 m/s etwa 2,5 MW und ab etwa 12 m/s seine vollen 4,2 MW. Schieben Sie den Windregler und sehen Sie zu, wie der Punkt auf der Kurve wandert.", + "p2": "Kein Windrad kann diese ganze Leistung einfangen. Um Energie zu entnehmen, muss der Rotor die Luft bremsen. Doch hinter ihm muss sie weiter abfließen können. Würde der Rotor die Luft ganz anhalten, käme keine neue mehr nach. Der Physiker Albert Betz hat gezeigt: Selbst ein perfekter Rotor nutzt höchstens 59,3 % der Leistung im Wind. Das ist die Betz-Grenze. Dieses Windrad schafft im besten Fall knapp die Hälfte, bei etwa 7 bis 8 m/s. Das Diagramm zeigt, wie nah es der Grenze kommt.", + "p3": "Ist die Volllast erreicht, bringt der Wind mehr, als der Generator aufnehmen kann. Deshalb drehen die Pitchzylinder die Blätter ein Stück aus dem Wind. Der meiste Wind strömt dann einfach vorbei. Bei 15 m/s nutzt das Windrad nur etwa 12 % der Leistung im Wind. Die Blattwinkel, die das Modell dann zeigt, hat es sich selbst ausgedacht. Die Werte der V150 sind nämlich nicht veröffentlicht. Über 20 m/s fängt das Windrad an, seine Leistung abzuregeln.", + "p4": "Bei der Abschaltgeschwindigkeit, etwa 22 bis 25 m/s, ist der Wind zu stark zum Arbeiten. Die Blätter drehen ihre Kante in den Wind. Das heißt Fahnenstellung, denn das Blatt steht nun wie eine Fahne im Wind. Der Rotor wird dabei von selbst langsamer, dann parkt ihn die Scheibenbremse. Das Windrad läuft erst wieder an, wenn der Wind um etwa 2 m/s nachgelassen hat. In diesem Modell halten alle 27 Windräder gemeinsam bei 24,5 m/s an und starten bei 22,5 m/s neu. Anhalten und Neustart dauern auf der Uhr jeweils 20 Minuten, länger als in Wirklichkeit. So können Sie in Ruhe zusehen." }, "wakes": { "title": "Warum Windräder so viel Abstand halten", - "p1": "Ein Windrad nimmt dem Wind Energie, deshalb ist die Luft dahinter langsamer. Diese Spur aus langsamer, verwirbelter Luft heißt Nachlauf. Schalten Sie Nachlauf (K) ein, dann sehen Sie hinter jedem drehenden Rotor einen Schweif. Am stärksten ist der Nachlauf bei mittlerem Wind, wenn der Rotor die Luft am kräftigsten bremst. Oberhalb der vollen Leistung lassen die Blätter mehr Wind vorbei, und der Nachlauf wird schwächer.", - "p2": "Das Modell zeichnet den Nachlauf nach einem einfachen, weit verbreiteten Rezept, dem Jensen-Modell. Es behandelt jeden Nachlauf als Kegel, der breiter wird und sich langsam wieder mit schnellerer Luft füllt. Wie schnell er breiter wird, legt eine einzige Zahl fest, hier 0,075, der übliche Wert an Land. Bei 8 m/s trifft ein Windrad 7 D weiter windabwärts noch auf Luft, die etwa 13 % langsamer ist. Weil die Leistung mit der dritten Potenz des Windes wächst, kostet das dieses Windrad etwa ein Drittel seiner Leistung.", - "p3": "Übers Jahr kostet der Nachlauf einen Windpark etwa 5 bis 15 % seiner Energie. Ein Beispielpark von Vestas mit 24 dieser Windräder verliert etwa 6 %. Für eine einzelne Windrichtung kann der Verlust sogar über 30 % liegen. Deshalb stehen Windräder in Windrichtung meist 5 bis 9 D auseinander und quer dazu 3 bis 5 D. Probieren Sie 5, 7 und 9 D aus: Mit größeren Abständen geht weniger Wind verloren, aber der Park braucht mehr Fläche.", - "p4": "Außerdem wird der Wind mit der Höhe stärker, weil der Boden die Luft in seiner Nähe bremst. Schalten Sie Luftströmung (F) ein, um das Profil zu sehen: Oben trifft der Rotor auf schnelleren Wind als unten. Die Schweife und Pfeile zeichnen Luft, die man in Wirklichkeit nicht sieht. In diesem Modell ist der Boden flach, ein stehender Rotor hinterlässt keinen Nachlauf, und wo sich Nachläufe überlappen, addieren sie sich auf einfache Weise." + "p1": "Ein Windrad nimmt dem Wind Energie. Deshalb ist die Luft dahinter langsamer. Diese Spur aus langsamer, verwirbelter Luft heißt Nachlauf, ähnlich wie das Kielwasser hinter einem Boot. Schalten Sie „Nachlauf (K)“ ein: Hinter jedem drehenden Rotor sehen Sie dann einen Schweif. Am stärksten ist der Nachlauf bei mittlerem Wind, denn dann bremst der Rotor die Luft am meisten. Ab Volllast lassen die Blätter mehr Wind vorbei, und der Nachlauf wird schwächer.", + "p2": "Das Modell zeichnet den Nachlauf nach einem einfachen, weit verbreiteten Rezept, dem Jensen-Modell. Darin ist jeder Nachlauf ein Kegel. Er wird nach hinten breiter und füllt sich langsam wieder mit schnellerer Luft. Wie schnell er breiter wird, legt eine einzige Zahl fest: hier 0,075, der übliche Wert an Land. Nehmen wir 8 m/s Wind. Ein Windrad steht 7 D hinter einem anderen. Bei ihm kommt die Luft noch etwa 13 % langsamer an. Die Leistung folgt der dritten Potenz der Geschwindigkeit. Darum verliert es etwa ein Drittel seiner Leistung.", + "p3": "Übers Jahr kostet der Nachlauf einen Windpark etwa 5 bis 15 % seiner Energie. In einem Beispielpark von Vestas mit 24 dieser Windräder sind es etwa 6 %. Kommt der Wind aus einer ungünstigen Richtung, kann der Verlust über 30 % steigen. Deshalb stehen die Reihen in Windrichtung meist 5 bis 9 D auseinander, die Windräder quer dazu 3 bis 5 D. Probieren Sie 5, 7 und 9 D aus: Mit größeren Abständen geht weniger an den Nachlauf verloren, aber der Park braucht mehr Fläche.", + "p4": "Außerdem wird der Wind mit der Höhe stärker, weil der Boden die Luft in seiner Nähe bremst. Schalten Sie „Luftströmung (F)“ ein, um das zu sehen: Oben trifft der Rotor auf schnelleren Wind als unten. Die Schweife und Pfeile sind nur gezeichnet. In diesem Modell ist der Boden flach, und ein stehender Rotor hinterlässt keinen Nachlauf. Wo sich Nachläufe überlagern, rechnet das Modell sie auf einfache Weise zusammen." }, "grid": { "title": "Der Weg ins Netz", - "p1": "Jeder Generator erzeugt Strom mit 800 Volt (V). Nach dem Umrichter erhöht der eigene Transformator des Windrads die Spannung. Höhere Spannung überträgt dieselbe Leistung mit weniger Strom, deshalb geht in den Kabeln weniger als Wärme verloren. Schalten Sie Kabel (C) ein, um den Strom von den Windrädern bis ins Netz zu verfolgen.", - "p2": "Sammelkabel mit 36.000 Volt, also 36 Kilovolt (kV), verbinden die Windräder mit dem Umspannwerk. Dort hebt ein weiterer Transformator die Spannung auf 110 kV für die Netzleitung. Diese Spannungen stammen aus einem Beispielpark von Vestas in Deutschland.", - "p3": "Bei voller Leistung erzeugt ein Windrad jede Stunde 4,2 Megawattstunden (MWh). Doch der Wind weht nicht immer kräftig, deshalb kommt ein Windrad übers Jahr im Schnitt auf etwa 40 % seiner vollen Leistung. Diesen Anteil nennt man Kapazitätsfaktor. In einem Jahr erzeugt ein Windrad etwa 14.700 MWh, genug für etwa 1.400 Haushalte in den USA. Der erfundene Tag des Modells ist windiger als der Jahresdurchschnitt, deshalb fällt sein Kapazitätsfaktor höher aus.", - "p4": "Auch der Bau eines Windrads kostet Energie und setzt CO2 frei. Über seine Lebensdauer kommen so auf jede erzeugte Kilowattstunde (kWh) etwa 7 g CO2e, wobei CO2e jedes Treibhausgas so zählt, als wäre es Kohlendioxid. Die Energie für seinen Bau hat es nach etwa 8 Monaten wieder eingespielt. Ausgelegt ist es auf 20 Jahre, oft läuft es länger, doch seine Blätter lassen sich noch immer schwer recyceln. Wählen Sie einen windarmen, typischen oder windreichen Standort, um zu sehen, wie sehr es auf den üblichen Wind vor Ort ankommt." + "p1": "Jeder Generator erzeugt Strom mit 800 Volt (V). Hinter dem Umrichter erhöht der eigene Transformator des Windrads die Spannung. Bei höherer Spannung fließt für dieselbe Leistung weniger Strom. So geht in den Kabeln weniger als Wärme verloren. Schalten Sie „Kabel (C)“ ein und folgen Sie dem Strom von den Windrädern bis ins Netz.", + "p2": "Sammelkabel mit 36.000 Volt, also 36 Kilovolt (kV), verbinden die Windräder mit dem Umspannwerk. Dort erhöht ein weiterer Transformator die Spannung noch einmal, auf 110 kV für die Netzleitung. Diese Spannungen stammen aus einem Beispielpark von Vestas in Deutschland.", + "p3": "Bei Volllast erzeugt ein Windrad jede Stunde 4,2 Megawattstunden (MWh). Doch der Wind weht nicht immer kräftig. Übers Jahr kommt ein Windrad im Schnitt auf etwa 40 % seiner vollen Leistung. Diesen Anteil, also seine Auslastung, nennt man Kapazitätsfaktor. In einem Jahr erzeugt ein Windrad so etwa 14.700 MWh. Das reicht für etwa 1.400 Haushalte in den USA. Der erfundene Tag im Modell ist windiger als ein durchschnittlicher Tag. Darum fällt sein Kapazitätsfaktor höher aus.", + "p4": "Auch der Bau eines Windrads kostet Energie und setzt CO2 frei. Über die ganze Lebensdauer kommen so etwa 7 g CO2e auf jede Kilowattstunde (kWh), die es liefert. CO2e rechnet jedes Treibhausgas so, als wäre es Kohlendioxid. Die Energie für seinen Bau hat ein Windrad nach etwa 8 Monaten wieder eingespielt. Fachleute nennen das energetische Amortisation. Ausgelegt ist es auf 20 Jahre, oft läuft es länger. Seine Blätter lassen sich allerdings noch schwer recyceln. Wählen Sie einen windarmen, typischen oder windreichen Standort und sehen Sie, wie viel der übliche Wind vor Ort ausmacht." } } } From 16f3340a3c19c0862afbe48587f99c95ff9330d9 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:25:36 +0200 Subject: [PATCH 036/132] fix(wind-farm): make the Chinese copy read naturally --- explainers/wind-farm/locales/zh.json | 128 +++++++++++++-------------- 1 file changed, 64 insertions(+), 64 deletions(-) diff --git a/explainers/wind-farm/locales/zh.json b/explainers/wind-farm/locales/zh.json index 29fda2ee..2165907a 100644 --- a/explainers/wind-farm/locales/zh.json +++ b/explainers/wind-farm/locales/zh.json @@ -2,10 +2,10 @@ "meta": { "title": "风电场是怎样工作的", "eyebrow": "可再生能源", - "tagline": "二十七台风机,一个有风的日子:像机翼一样的叶片,怎样把流动的空气变成电,送进电网。", - "description": "跟着3D风电场度过有风的一天:叶片怎样抓住风,机舱里什么在转,风机为什么隔得很远,电又怎样送进电网。", - "summary": "风电场布局、塔筒与叶片、齿轮箱与发电机、功率曲线与贝茨极限、尾流,还有通往电网的路。", - "socialAlt": "起伏的田野上有一座风电场,近处是一台风机的特写,旁边是标题“风电场是怎样工作的”。" + "tagline": "27台风机,刮风的一天:形状像机翼的叶片,怎样把流动的空气变成送进电网的电。", + "description": "跟着3D风电场过完刮风的一天:叶片怎样借风转起来,机舱里是什么在转,风机为什么隔得很远,电又怎样送进电网。", + "summary": "风电场布局、塔筒与叶片、齿轮箱与发电机、功率曲线与贝茨极限、尾流,以及通往电网的路。", + "socialAlt": "起伏的田野上有一座风电场,近处是一台风机的特写,旁边是标题“风电场是怎样工作的”" }, "stage": { "label": "风电场互动模型", @@ -17,7 +17,7 @@ "speed": "播放速度", "value": "{{time}}", "during": "{{time}},{{phase}}", - "speedValue": "{{value}}小时/分钟", + "speedValue": "{{value}}小时/分钟", "speedFormat": "每分钟播放一天中的{{value}}小时", "phase": { "night": "无风的夜晚", @@ -29,7 +29,7 @@ "jump": { "night": "跳到无风的夜晚", "morning": "跳到早晨的微风", - "afternoon": "跳到满功率", + "afternoon": "跳到满功率的时段", "storm": "跳到风暴", "evening": "跳到傍晚" } @@ -40,7 +40,7 @@ "siteWindOptions": { "calm": "风小的场址", "typical": "普通场址", - "windy": "多风的场址" + "windy": "风大的场址" }, "spacingOptions": { "5": "5 D", @@ -52,12 +52,12 @@ "open": "打开外罩" }, "windAtOptions": { - "day": "跟随这一天", - "cutIn": "切入风速,3米/秒", - "peak": "效率最高,8米/秒", - "rated": "满功率,12米/秒", - "rampDown": "功率下调,23米/秒", - "cutOut": "已过切出风速,25米/秒" + "day": "按这一天的风", + "cutIn": "切入风速,3米/秒", + "peak": "效率最高,8米/秒", + "rated": "满功率,12米/秒", + "rampDown": "降功率,23米/秒", + "cutOut": "超过切出风速,25米/秒" }, "groups": { "siteWind": "场址风况", @@ -94,18 +94,18 @@ "farm": "整座风电场", "state": { "idle": "等风中", - "partial": "部分负荷", + "partial": "未到满功率", "full": "满功率", - "rampDown": "功率下调", + "rampDown": "降功率", "stopping": "顺桨停机中", "parked": "已停机", - "starting": "正在启动" + "starting": "启动中" } }, "units": { - "metresPerSecond": "{{value}}米/秒", - "speedPair": "{{ms}}米/秒,{{kmh}}公里/小时", - "rpm": "{{value}}转/分", + "metresPerSecond": "{{value}}米/秒", + "speedPair": "{{ms}}米/秒,{{kmh}}公里/小时", + "rpm": "{{value}}转/分", "degrees": "{{value}}°", "mw": "{{value}}兆瓦", "mwh": "{{value}}兆瓦时", @@ -121,21 +121,21 @@ }, "chapters": { "farm": { - "count": "风机数量", - "rated": "风电场满功率", - "rows": "排与排之间,顺着风向", + "count": "风机台数", + "rated": "全场满功率", + "rows": "排与排之间(顺风向)", "across": "同一排风机之间", "spacingValue": "{{metres}}米,{{diameters}} D", - "built": "实际占用的土地", + "built": "实际占地", "project": "项目总面积" }, "tower": { "wind": "轮毂高度的风速", - "rpm": "转子转速", + "rpm": "风轮转速", "turn": "转一圈用时", - "stopped": "转子已停", + "stopped": "风轮已停转", "tip": "叶尖速度", - "height": "叶尖最高处", + "height": "叶尖最高点", "blade": "叶片长度", "swept": "扫掠面积", "sweptValue": "{{area}}平方米,约{{pitches}}个足球场" @@ -145,11 +145,11 @@ "generator": "发电机", "gear": "增速比", "gearValue": "约1比{{ratio}}", - "pitch": "叶片桨距角", + "pitch": "桨距角", "brake": "盘式制动器", "brakeValue": { "released": "已松开", - "holding": "正在锁住转子" + "holding": "锁住风轮" }, "volts": "发电机电压" }, @@ -157,26 +157,26 @@ "wind": "风速", "state": "风机状态", "power": "风机输出", - "caption": "风中的功率、贝茨极限和这台风机的输出,风速从0到30米/秒", - "axisWind": "风速(米/秒)", + "caption": "风的功率、贝茨极限和这台风机的输出,风速从0到30米/秒", + "axisWind": "风速(米/秒)", "axisPower": "功率(兆瓦)", - "lineWind": "风中的功率", + "lineWind": "风的功率", "lineBetz": "贝茨极限", "lineTurbine": "这台风机", - "windPower": "风中的功率", + "windPower": "风的功率", "cp": "抓住的比例", - "betz": "相当于贝茨极限的比例", - "pitch": "叶片桨距角" + "betz": "占贝茨极限的比例", + "pitch": "桨距角" }, "wakes": { "spacing": "排间距", - "deficit": "尾流让下一排的风变慢", - "loss": "此刻尾流造成的全场损失", - "top": "转子顶部的风速", - "bottom": "转子底部的风速" + "deficit": "尾流让下一排风速降低", + "loss": "此刻全场因尾流损失的功率", + "top": "风轮顶部的风速", + "bottom": "风轮底部的风速" }, "grid": { - "output": "风电场此刻的输出", + "output": "全场此刻的输出", "energy": "今天的发电量", "homes": "此刻的电够多少户美国家庭用", "capacity": "这一天的容量系数", @@ -224,46 +224,46 @@ }, "sections": { "farm": { - "title": "二十七台风机,一个有风的日子", - "p1": "这是一座风电场:27台风机散布在起伏的田野上。每台都是维斯塔斯V150,满功率时能发出4.2兆瓦(MW)的电。1兆瓦就是100万瓦,所以整座风电场加起来能发约113兆瓦。它的规模和瑞典的Målarberget风电场差不多,那是一座真实的风电场,同样有27台这种风机,2021年起投入运行。", - "p2": "风机分成几排,每一排都横对着主导风向,也就是风最常吹来的方向。这里的主导风向是西风。工程师用转子直径来衡量间距,记作D,这里1 D是150米。排与排相隔7 D,也就是1050米;同一排的风机相隔4 D,也就是600米。每一排都往旁边错开一些,所以没有哪台风机正好站在前一排某台风机的正后方。", - "p3": "从空中看,风机好像很密,其实大部分土地仍是农田。每兆瓦真正占用的土地只有约0.3公顷,而且大多是检修道路,不是风机底座。1公顷就是边长100米的正方形。电沿着集电电缆汇到变电站,再从那里送往电网。", - "p4": "Målarberget的真实布局没有公开,所以你看到的是这个模型自己的布局。模型还会循环播放虚构的一天里的风。这并不是寻常的一天:它把无风的夜晚、有微风的早晨、满功率的下午和傍晚的风暴都塞进了24小时。时钟比真实时间快几百倍,但每个转子都按真实的速度转。为了让你在几公里外也能看清,道路、电缆和风向箭头都画得比实际宽,光照也不跟着时钟变化。" + "title": "27台风机,刮风的一天", + "p1": "这是一座风电场:27台风机散布在起伏的田野上。每台都是维斯塔斯V150,满功率时能发出4.2兆瓦(MW)的电力。兆瓦就是100万瓦,所以整座风电场最多能发出约113兆瓦。瑞典的Målarberget风电场正是这个规模,那是一座真实的风电场,也由27台这种风机组成,2021年起投入运行。", + "p2": "风机排成一排排,横对着主导风向,也就是风最常吹来的方向。这里的主导风向是西风。工程师习惯用风轮直径来量间距,记作D。1 D就是风轮从一头叶尖到另一头叶尖的距离,这里是150米。排与排相隔7 D,也就是1050米。同一排里,风机相隔4 D,也就是600米。每一排都往旁边错开一点,这样就没有哪台风机正好躲在另一台的正后方。", + "p3": "从空中看,风机好像挤得很密,其实大部分土地还是农田。每兆瓦真正占用的土地只有约0.3公顷。一公顷就是边长100米的正方形。这些占地大多是检修道路,并不是风机底座。集电电缆把每台风机发的电收集起来,送到变电站,电在那里并入电网。", + "p4": "Målarberget的真实布局没有公开,所以这里的布局是模型自己排的。这一天的风也是编出来的,会一直循环播放。它可不是寻常的一天:无风的夜晚、微风习习的早晨、满功率运行的下午和傍晚的风暴,全都挤进了24小时。时钟比现实快几百倍,但每个风轮都按真实的转速在转。道路、电缆和风向箭头都画得比实际宽,这样你在几公里外也能看清。不管几点,光照都一样。" }, "tower": { "title": "绕着圈飞的叶片", - "p1": "走近一看,一台风机大得惊人。钢制塔筒把轮毂举到离地105米的高处。转子就是轮毂加上三片叶片,直径有150米。叶片转到最高点时,叶尖离地约180米,差不多是大本钟的两倍高。那么高的地方,风比地面附近更快,第5章会讲到。", - "p2": "每片叶片长约74米,差不多和最大的客机空客A380的翼展一样长。它由碳纤维和玻璃纤维嵌在环氧树脂里做成,环氧树脂是一种坚硬的塑料胶。叶片不是靠风推着走的帆。风像流过飞机机翼那样流过叶片,弯曲的外形会产生升力,这是一种和风向垂直的力。升力拉着每片叶片往前跑,转子就转起来了。", - "p3": "转子全速时每分钟只转约10圈,记作10转/分。也就是6秒转一圈,看起来慢悠悠的。可叶尖绕的圆非常大,所以速度能达到约80米/秒,接近每小时300公里。拖动风速滑块,看转子怎样随风加快;选“跟随这一天”,风速就重新交给时钟。在这个模型里,转子达到最高转速之前,叶尖速度一直是风速的8倍。", - "p4": "叶片扫过的圆叫扫掠面积:约1.77万平方米,大约有2.5个足球场那么大。只有穿过这个圆的风,才能让转子转动。转子周围的气流线只是示意,真实的空气是看不见的。在这个模型里,变压器放在塔筒底部,因为V150实际把它装在哪里没有公开。出于同样的原因,转子那一点轻微的倾角也没有画出来。" + "p1": "走近一看,一台风机大得惊人。三片叶片装在一个圆圆的轮毂上,合起来叫风轮,直径有150米。钢制的塔筒把轮毂托到离地105米的高处。叶片转到最高点时,叶尖离地约180米,差不多是大本钟的两倍高。那么高的地方,风比地面附近更快,第5章会讲到。", + "p2": "每片叶片长约74米,快赶上最大的客机空客A380的翼展了。叶片用碳纤维和玻璃纤维嵌在环氧树脂里制成,环氧树脂是一种很硬的塑料胶。风并不像推帆那样推着叶片走,而是像流过飞机机翼那样流过叶片。叶片弯曲的外形会产生升力,这是一个和风向垂直的力。升力拉着每片叶片往前转,风轮就转起来了。", + "p3": "全速时,风轮每分钟也只转约10圈,也就是10转/分。算下来6秒才转一圈,看上去懒洋洋的。可叶尖绕的圆实在太大,速度能达到约80米/秒,时速接近300公里。拖动风速滑块,看风轮怎样随着风加快。选“按这一天的风”,风速就重新由时钟来定。在这个模型里,风轮达到最高转速之前,叶尖速度一直是风速的8倍。", + "p4": "叶片转一圈扫过的圆,面积就叫扫掠面积:约1.77万平方米,差不多有2.5个足球场大。只有穿过这个圆的风,才能让风轮转动。风轮周围那些气流线只是示意,真实的空气是看不见的。风机自带的变压器会把电压升高,方便电缆输送。V150把变压器装在哪里没有公开,所以模型把它放在塔筒脚下。同样因为没有公开,风轮那一点轻微的仰角也没有画出来。" }, "nacelle": { "title": "机舱里有什么", - "p1": "轮毂后面那个长长的箱子是机舱,长12.8米。选“打开外罩”就能看到里面。里面的机器把转子又慢又有力的转动变成电。它们都装在底座上,那是一个沉重的铸铁框架,机舱顶上的冷却器则负责把它们的热量带走。", - "p2": "在轮毂里,每片叶片都装在一个轴承上,所以能绕自身转动。三个变桨油缸各管一片叶片,靠油压来扭转叶片。叶片相对于风的角度叫桨距角,它决定叶片能吃到多少风。轮毂后面,主轴在主轴承里转动,转速和转子一样慢,约10转/分。", - "p3": "发电机需要转得很快,所以中间要加一个齿轮箱。它有两级行星齿轮,小齿轮围着中间的大齿轮转,还有一级斜齿轮,齿是斜着的。几级合在一起,让转速变成原来的约140倍,最高约1485转/分。发电机是一台感应发电机,用铜绕组,没有磁铁。它以800伏发出的电全部要经过变流器,由变流器调整成适合电网的样子。", - "p4": "主轴上装着盘式制动器。风暴来时让风机停下的并不是它,而是叶片:叶片会侧过来,让窄窄的边缘对着风。制动器只在转子慢下来以后把它锁住,让它停稳。塔筒顶上的偏航驱动会转动整个机舱,让它对着来风。在这个模型里,机舱完全跟着风转,因为V150实际转得多快没有公开。" + "p1": "轮毂后面那个大箱子叫机舱,长12.8米。选“打开外罩”就能看到里面。风轮转得慢,但力气很大,机舱里的机器负责把这种转动变成电。这些机器都装在底座上,那是一个沉重的铸铁框架。机舱顶上的冷却器负责把它们产生的热量带走。", + "p2": "叶片对着风的角度叫桨距角,它决定叶片能吃到多少风。每片叶片都通过一个轴承装在轮毂上,所以能绕着自己转动。三个变桨油缸,每片叶片各配一个,靠油压来转动叶片。轮毂后面,主轴在主轴承里转动,转速和风轮一样慢,约10转/分。", + "p3": "发电机要转得很快才行,所以中间加了一个齿轮箱。它有两级行星齿轮,就是几个小齿轮围着中间的齿轮转,还有一级斜齿轮,齿是斜着切的。几级合起来,让转速变成原来的约140倍,最高约1485转/分。发电机是一台感应发电机,用的是铜绕组,没有磁铁。它发出的电是800伏,全部都要经过变流器。变流器会把电重新调整一遍,让它和电网保持同步。", + "p4": "主轴上装着一个盘式制动器,也就是刹车。不过风暴来时,让风机停下的并不是它,而是叶片:叶片会侧过身来,用边缘对着风。刹车只在风轮慢下来以后才把它锁住,让它停稳。塔筒顶上的偏航驱动会转动整个机舱,让它正对来风,这种转动就叫偏航。在这个模型里,机舱总是分毫不差地跟着风转,因为V150实际转得多快没有公开。" }, "curve": { "title": "一台风机能抓住多少风", - "p1": "风速不到3米/秒时,转子只是等着,这个风速叫切入风速。一过这个风速,功率就快速上升,因为风中的功率和风速的三次方成正比,也就是速度乘速度再乘速度。风速翻一倍,风带来的功率就变成八倍。所以这台风机在4米/秒时约发0.3兆瓦,8米/秒时约2.5兆瓦,从约12米/秒起达到满功率4.2兆瓦。拖动风速滑块,看那个点沿着曲线移动。", - "p2": "没有哪台风机能把风的功率全部抓住。要取走能量,转子就得让空气慢下来,可空气还得能从它后面流走。物理学家阿尔伯特·贝茨证明,即使是完美的转子,最多也只能取走风中59.3%的功率。这就是贝茨极限。这台风机在约7到8米/秒时表现最好,能抓住略少于一半的功率,图上能看出它离这个极限有多近。", - "p3": "风速一旦超过满功率点,风带来的功率就多于发电机能接收的。于是变桨油缸把叶片稍稍转离风向,让大部分风从旁边溜走。在15米/秒时,风机只留下风中约12%的功率。满功率以上显示的桨距角是这个模型自己设定的,因为V150的实际数值没有公开。风速超过20米/秒后,功率开始下调。", - "p4": "到了约22到25米/秒的切出风速,叶片会侧过来,让边缘对着风。这叫顺桨:转子自己慢下来,然后盘式制动器让它停住。风速要回落约2米/秒,风机才会重新启动。在这个模型里,27台风机在24.5米/秒时一起停机,在22.5米/秒时重新启动。停机和重启各占时钟上的20分钟,比实际更长,好让你看清整个过程。" + "p1": "风轮会一直等到风速达到3米/秒,这个风速叫切入风速。过了这一点,功率就快速往上爬。风的功率和风速的三次方成正比,也就是速度乘速度再乘速度。风速翻一倍,风带来的功率就变成8倍。所以这台风机在4米/秒时约发0.3兆瓦,8米/秒时约发2.5兆瓦,从约12米/秒起就能发满4.2兆瓦。拖动风速滑块,看那个点沿着曲线移动。", + "p2": "不过,没有哪台风机能把这些功率全部抓住。要从风里取走能量,风轮就得让空气慢下来,可空气还得从它后面流走。要是风轮把空气完全挡住,新的空气就进不来了。物理学家阿尔伯特·贝茨证明,即使是完美的风轮,最多也只能取走风中59.3%的功率,这就是贝茨极限。这台风机最好的时候能抓住将近一半,出现在约7到8米/秒的风速下。从图上能看出它离这个极限有多近。", + "p3": "风速超过满功率点以后,风带来的功率就比发电机能接住的还多。于是变桨油缸把叶片稍稍转离风向,让大部分风从旁边溜走。在15米/秒时,风机只留下风中约12%的功率。模型在满功率以上显示的桨距角是它自己定的,因为V150的实际数值没有公开。风速超过20米/秒后,风机开始逐步降低功率。", + "p4": "到了切出风速,也就是约22到25米/秒,风就太猛了,风机没法再干活。叶片会侧过身来,用边缘对着风。这叫顺桨:风轮自己慢下来,然后由盘式制动器把它锁住停稳。风速要回落约2米/秒,风机才会重新启动。在这个模型里,27台风机在24.5米/秒时一起停机,到22.5米/秒时再重新启动。停机和重启在时钟上各要20分钟,比现实中慢,好让你看清楚。" }, "wakes": { "title": "风机为什么隔得这么远", - "p1": "风机从风里取走能量,所以它身后的空气会变慢。这股又慢又乱的气流叫尾流。打开“尾流(K)”,就能看到每个转动的转子后面都拖着一道尾迹。风不大不小时尾流最强,因为这时转子对空气的阻挡最厉害。超过满功率后,叶片放更多的风溜过去,尾流也就变弱了。", - "p2": "这个模型用一种简单又常用的方法来画尾流,叫詹森模型。它把每道尾流看成一个圆锥,越往后越宽,并慢慢被较快的空气重新填满。圆锥张开的快慢由一个数字决定,这里取0.075,是陆上的常用值。风速8米/秒时,下风向7 D处的风机迎来的风仍要慢约13%。由于功率随风速的三次方变化,这会让它少发约三分之一的电。", - "p3": "按一整年算,尾流会让一座风电场少发约5%到15%的电。维斯塔斯举过一个例子,一座有24台这种风机的风电场,损失约6%。如果只看某一个风向,损失可能超过30%。所以风机通常沿风向相隔5到9 D,横向相隔3到5 D。试试5 D、7 D和9 D:间距越大,损失的风越少,但要占的地也越多。", - "p4": "风速还会随高度增加,因为地面会拖住贴近它的空气。打开“气流(F)”就能看到不同高度的风速:转子顶部迎来的风比底部更快。尾迹和箭头都只是对看不见的空气的示意。在这个模型里,地面是平的,停转的转子不留尾流,相互重叠的尾流也只是用简单的方法叠加。" + "p1": "风机从风里取走了能量,所以它身后的空气就慢了下来。这一道又慢又乱的气流叫尾流,就像船尾拖出的那道水痕。打开“尾流(K)”,每个转动的风轮后面都会拖出一道尾迹。风不大不小的时候尾流最强,因为这时风轮对空气的阻挡最厉害。风速超过满功率点后,叶片放更多的风溜过去,尾流也就淡了。", + "p2": "这个模型用一个简单又常用的办法来画尾流,叫Jensen模型。它把每道尾流看成一个圆锥,越往后越宽,周围较快的空气也慢慢补进来。圆锥张开得多快,由一个数决定:这里取0.075,是陆上常用的值。风速8米/秒时,顺着风往后7 D处的风机,迎来的风仍要慢约13%。功率跟着风速的三次方走,所以它要少发约三分之一的电。", + "p3": "按一整年算,尾流会让一座风电场少发约5%到15%的电。维斯塔斯举过一个示例风电场,用了24台这种风机,损失约6%。要是风正好从某个倒霉的方向吹来,损失可能超过30%。所以顺着风向,排与排通常相隔5到9 D,同一排里的风机相隔3到5 D。试试“5 D”“7 D”和“9 D”:间距越大,被尾流吃掉的电越少,但要占的地也越多。", + "p4": "风还会越高越强,因为地面会拖住贴近它的空气。打开“气流(F)”就能看到:风轮顶部迎来的风比底部更快。那些尾迹和箭头都只是示意图。在这个模型里,地面是平的,停转的风轮不留尾流,几道尾流叠在一起时,也只是按简单的办法相加。" }, "grid": { "title": "通往电网的路", - "p1": "每台发电机发出的电是800伏(V)。电经过变流器后,风机自带的变压器会把电压升高。电压越高,输送同样的功率需要的电流就越小,电缆里白白变成热量的电也就越少。打开“电缆(C)”,就能跟着电从风机一路走到电网。", - "p2": "集电电缆的电压是3.6万伏,也就是36千伏(kV),它把各台风机连到变电站。在那里,另一台变压器再把电压升到110千伏,送上输电线路。这些电压数值来自维斯塔斯在德国的一个示例风电场。", - "p3": "满功率时,一台风机每小时发电4.2兆瓦时(MWh)。但风不会一直那么大,所以一年下来,一台风机的平均出力约为满功率的40%。这个比例叫容量系数。一台风机一年约发电1.47万兆瓦时,够约1400户美国家庭使用。模型里虚构的那一天比全年平均风大,所以算出来的容量系数更高。", - "p4": "造风机本身也要消耗能量、排放碳。在整个寿命里,它每发1千瓦时(kWh)电,约排放7克二氧化碳当量(CO2e),也就是把各种温室气体都折算成二氧化碳来算。它用约8个月就能把建造时消耗的能量赚回来。风机按20年设计,实际常常用得更久,不过叶片至今仍很难回收。在“场址风况”里选风小、普通或多风的场址,看看日常的风有多重要。" + "p1": "每台发电机发出的电是800伏(V)。电经过变流器之后,风机自带的变压器会把电压升高。电压越高,输送同样的功率所需的电流就越小,电缆里白白变成热的电也就越少。打开“电缆(C)”,就能跟着电从风机一路走到电网。", + "p2": "集电电缆的电压是3.6万伏,也就是36千伏(kV),它们把各台风机连到变电站。在变电站里,另一台变压器再把电压升到110千伏,送上输电线路。这些电压数值取自维斯塔斯在德国的一个示例风电场。", + "p3": "满功率时,一台风机每小时能发4.2兆瓦时(MWh)的电。可风不会一直那么大。一年平均下来,一台风机的输出约为满功率的40%,这个比例就是它的容量系数。一台风机一年约发1.47万兆瓦时的电,够约1400户美国家庭使用。模型里这一天是编出来的,风比平常的日子大,所以算出的容量系数也偏高。", + "p4": "造一台风机,本身也要耗能、排碳。算上整个寿命,它每发1千瓦时(kWh,也就是1度)电,约排放7克CO2e。CO2e叫二氧化碳当量,就是把各种温室气体都折算成二氧化碳来计算。大约8个月,风机就能把建造时用掉的能量发回来。风机按20年设计,实际常常用得更久,不过叶片至今仍很难回收。选“风小的场址”“普通场址”或“风大的场址”,看看一个地方平时的风有多重要。" } } } From 112aec3add3b0b8c855bd906a27bebab9dcd81fd Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:26:17 +0200 Subject: [PATCH 037/132] fix(wind-farm): make the French copy read naturally --- explainers/wind-farm/locales/fr.json | 64 ++++++++++++++-------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/explainers/wind-farm/locales/fr.json b/explainers/wind-farm/locales/fr.json index 17300d4e..dd661e33 100644 --- a/explainers/wind-farm/locales/fr.json +++ b/explainers/wind-farm/locales/fr.json @@ -4,8 +4,8 @@ "eyebrow": "Énergies renouvelables", "tagline": "Vingt-sept éoliennes et une journée de vent : comment une pale en forme d’aile transforme le vent en électricité pour le réseau.", "description": "Un jour de vent dans un parc éolien en 3D : les pales qui captent l’air, ce qui tourne dans la nacelle, l’écart entre éoliennes et la route vers le réseau.", - "summary": "Le plan du parc, le mât et les pales, le multiplicateur et la génératrice, la courbe de puissance et la limite de Betz, les sillages et le chemin vers le réseau.", - "socialAlt": "Un parc éolien sur des collines douces, avec une éolienne en gros plan, à côté du titre « Comment fonctionne un parc éolien »" + "summary": "Le plan du parc, le mât et les pales, le multiplicateur et la génératrice, la courbe de puissance et la limite de Betz, les sillages et le chemin jusqu’au réseau.", + "socialAlt": "Un parc éolien dans un paysage vallonné, avec une éolienne en gros plan, à côté du titre « Comment fonctionne un parc éolien »" }, "stage": { "label": "Modèle interactif de parc éolien", @@ -18,7 +18,7 @@ "value": "{{time}}", "during": "{{time}}, {{phase}}", "speedValue": "{{value}} h/min", - "speedFormat": "{{value}} heures de la journée par minute", + "speedFormat": "{{value}} heures de la journée par minute", "phase": { "night": "Nuit calme", "morning": "Brise du matin", @@ -99,7 +99,7 @@ "rampDown": "Baisse progressive", "stopping": "Mise en drapeau", "parked": "À l’arrêt", - "starting": "Démarrage" + "starting": "Mise en route" } }, "units": { @@ -144,12 +144,12 @@ "shaft": "Arbre principal", "generator": "Génératrice", "gear": "Rapport de multiplication", - "gearValue": "environ 1 pour {{ratio}}", + "gearValue": "environ 1 à {{ratio}}", "pitch": "Calage des pales", "brake": "Frein à disque", "brakeValue": { "released": "Desserré", - "holding": "Retient le rotor" + "holding": "Immobilise le rotor" }, "volts": "Tension de la génératrice" }, @@ -170,8 +170,8 @@ }, "wakes": { "spacing": "Écart entre les rangées", - "deficit": "Ralentissement dû au sillage à la rangée suivante", - "loss": "Pertes dues aux sillages en ce moment", + "deficit": "Ralentissement dû au sillage, rangée suivante", + "loss": "Puissance du parc perdue dans les sillages", "top": "Vent en haut du rotor", "bottom": "Vent en bas du rotor" }, @@ -225,45 +225,45 @@ "sections": { "farm": { "title": "Vingt-sept éoliennes et une journée de vent", - "p1": "Voici un parc éolien : 27 éoliennes réparties sur des champs vallonnés. Ce sont toutes des Vestas V150, et chacune produit 4,2 mégawatts (MW) à pleine puissance. Un mégawatt, c’est un million de watts. Au total, le parc peut donc fournir environ 113 MW. Il a la taille de Målarberget, en Suède : un vrai parc de 27 de ces éoliennes en service depuis 2021.", - "p2": "Les éoliennes forment des rangées placées en travers du vent dominant, c’est-à-dire la direction d’où le vent souffle le plus souvent. Ici, il vient de l’ouest. Les ingénieurs mesurent les écarts en diamètres de rotor, notés D, et un D mesure 150 m pour la V150. Les rangées sont à 7 D les unes des autres, soit 1 050 m, et dans une rangée, les éoliennes sont à 4 D, soit 600 m. Chaque rangée est décalée sur le côté, pour qu’aucune éolienne ne se trouve juste derrière une autre de la rangée de devant.", - "p3": "Vu d’en haut, le parc paraît encombré, pourtant l’essentiel du terrain reste en champs. On ne construit qu’environ 0,3 hectare par mégawatt, et ce sont surtout des chemins d’accès, pas des socles d’éoliennes. Un hectare, c’est un carré de 100 m de côté. Le courant passe par des câbles de collecte jusqu’à un poste électrique, d’où il part vers le réseau.", - "p4": "Le vrai plan de Målarberget n’est pas publié : celui que vous voyez est propre à ce modèle. Le modèle rejoue aussi en boucle une journée de vent inventée. Ce n’est pas une journée moyenne : elle réunit en 24 heures une nuit calme, une matinée de brise, un après-midi à pleine puissance et une tempête le soir. L’horloge va des centaines de fois plus vite que la réalité, mais chaque rotor tourne à sa vraie vitesse. Les routes, les câbles et les flèches de vent sont dessinés plus larges que nature pour rester visibles à des kilomètres, et la lumière ne suit pas l’horloge." + "p1": "Voici un parc éolien : 27 éoliennes réparties sur des champs vallonnés. Ce sont toutes des Vestas V150, et chacune produit 4,2 mégawatts (MW) à pleine puissance. Un mégawatt, c’est un million de watts : le parc entier peut donc fournir environ 113 MW. On retrouve ces chiffres à Målarberget, en Suède, un vrai parc de 27 de ces éoliennes, en service depuis 2021.", + "p2": "Les éoliennes sont alignées en rangées, en travers du vent dominant, c’est-à-dire la direction d’où le vent souffle le plus souvent. Ici, il vient de l’ouest. Les ingénieurs mesurent les écarts en diamètres de rotor, notés D. Un D, c’est la largeur du rotor d’un bout de pale à l’autre : 150 m ici. Les rangées sont à 7 D les unes des autres, soit 1 050 m. Dans une rangée, deux éoliennes voisines sont à 4 D l’une de l’autre, soit 600 m. Chaque rangée est un peu décalée sur le côté, pour qu’aucune éolienne ne se cache juste derrière une autre.", + "p3": "Vu d’en haut, le parc semble encombré. Pourtant, l’essentiel du terrain est resté en champs. On ne construit vraiment qu’environ 0,3 hectare par mégawatt. Un hectare, c’est un carré de 100 m de côté. Et ce terrain construit, ce sont surtout des chemins d’accès, pas des socles d’éoliennes. Des câbles de collecte rassemblent le courant de chaque éolienne et l’amènent jusqu’à un poste électrique, où il rejoint le réseau.", + "p4": "Le vrai plan de Målarberget n’est pas publié : celui-ci est donc propre au modèle. La journée de vent est inventée, elle aussi, et elle défile en boucle. Ce n’est pas une journée moyenne : elle réunit en 24 heures une nuit calme, une matinée de brise, un après-midi à pleine puissance et une tempête en soirée. L’horloge va des centaines de fois plus vite que la réalité, mais chaque rotor tourne à sa vraie vitesse. Les routes, les câbles et les flèches de vent sont dessinés plus larges que nature, pour que vous puissiez les voir à des kilomètres. Enfin, la lumière reste la même à toute heure." }, "tower": { "title": "Des pales qui volent en rond", - "p1": "De près, une éolienne est immense. Le mât en acier porte le moyeu à 105 m du sol, et le rotor, c’est-à-dire le moyeu et ses trois pales, mesure 150 m de diamètre. En haut de sa course, le bout d’une pale atteint environ 180 m, presque deux fois la hauteur de Big Ben. Là-haut, le vent souffle plus vite qu’au ras du sol, comme le montre le chapitre 5.", - "p2": "Chaque pale mesure environ 74 m, presque l’envergure d’un Airbus A380, le plus gros avion de ligne. Elle est faite de fibres de carbone et de verre noyées dans de l’époxy, une colle plastique très dure. Une pale n’est pas une voile que le vent pousse. L’air glisse dessus comme sur l’aile d’un avion, et sa forme bombée crée une portance, une force qui agit en travers du vent. La portance entraîne chaque pale, et le rotor se met à tourner.", - "p3": "À pleine vitesse, le rotor ne fait qu’environ 10 tours par minute, ce qu’on note 10 tr/min. Un tour toutes les 6 secondes : cela paraît bien paresseux. Mais le bout des pales parcourt un cercle immense, si bien qu’il file à environ 80 m/s, près de 300 km/h. Faites glisser le curseur du vent pour voir le rotor accélérer avec lui, ou choisissez « Suivre la journée » pour que le vent suive à nouveau l’horloge. Dans ce modèle, le bout des pales va 8 fois plus vite que le vent, jusqu’à ce que le rotor atteigne sa vitesse maximale.", - "p4": "Le cercle que balaient les pales, c’est la surface balayée : environ 17 700 m², soit à peu près 2,5 terrains de football. Seul le vent qui traverse ce cercle peut faire tourner le rotor. Les lignes d’air autour du rotor sont dessinées pour vous, car en vrai, l’air est invisible. Dans ce modèle, le transformateur se trouve au pied du mât, car son emplacement réel sur la V150 n’est pas publié. Pour la même raison, la légère inclinaison du rotor n’est pas dessinée." + "p1": "De près, une éolienne est gigantesque. Le rotor se compose de trois pales fixées sur un moyeu rond, et mesure 150 m de diamètre. Le mât en acier porte ce moyeu à 105 m du sol. En haut de sa course, le bout d’une pale monte à environ 180 m, presque deux fois la hauteur de Big Ben. Là-haut, le vent souffle plus vite qu’au ras du sol, comme le montre le chapitre 5.", + "p2": "Chaque pale mesure environ 74 m, presque l’envergure d’un Airbus A380, le plus gros avion de ligne. Elle est faite de fibres de carbone et de verre noyées dans de l’époxy, une colle plastique très dure. Le vent ne pousse pas la pale comme une voile. Il glisse dessus, comme sur l’aile d’un avion. La forme bombée de la pale crée une portance, une force perpendiculaire au vent. C’est la portance qui entraîne chaque pale, et le rotor se met à tourner.", + "p3": "À pleine vitesse, le rotor ne fait qu’environ 10 tours par minute, ce qu’on note 10 tr/min. Un tour toutes les 6 secondes : on dirait qu’il paresse. Mais le bout des pales parcourt un cercle immense : il file donc à environ 80 m/s, près de 300 km/h. Faites glisser le curseur du vent et regardez le rotor accélérer avec lui. Choisissez « Suivre la journée » pour que l’horloge reprenne la main sur le vent. Dans ce modèle, le bout des pales va 8 fois plus vite que le vent, jusqu’à ce que le rotor atteigne sa vitesse maximale.", + "p4": "Le cercle que balaient les pales, c’est la surface balayée : environ 17 700 m², soit à peu près 2,5 terrains de football. Seul le vent qui traverse ce cercle peut faire tourner le rotor. Les lignes d’air autour du rotor ne sont qu’un dessin, car en vrai, l’air est invisible. Le transformateur de l’éolienne élève la tension de son courant pour l’envoyer dans les câbles. Son emplacement sur la V150 n’est pas publié : ce modèle le place donc au pied du mât. Pour la même raison, la légère inclinaison du rotor n’est pas dessinée." }, "nacelle": { "title": "Dans la nacelle", - "p1": "Le grand caisson derrière le moyeu, c’est la nacelle, qui mesure 12,8 m de long. Choisissez « Sans capot » pour voir l’intérieur. Elle abrite les machines qui transforment la rotation lente et puissante du rotor en électricité. Toutes reposent sur le châssis, un lourd cadre en fonte, et un refroidisseur sur le toit évacue leur chaleur.", - "p2": "Dans le moyeu, chaque pale est montée sur un roulement qui lui permet de pivoter. Trois vérins de calage, un par pale, les font pivoter grâce à la pression d’huile. L’angle de la pale face au vent s’appelle le calage, et il règle la prise que le vent a sur elle. Derrière le moyeu, l’arbre principal tourne dans son roulement principal à la même vitesse lente que le rotor, environ 10 tr/min.", - "p3": "Une génératrice doit tourner vite : un multiplicateur s’intercale donc entre l’arbre et elle. Il compte deux étages planétaires, où de petits engrenages tournent autour d’un engrenage central, et un étage hélicoïdal aux dents inclinées. Ensemble, ils multiplient la vitesse de l’arbre par environ 140, jusqu’à environ 1 485 tr/min. La génératrice est de type asynchrone : elle a des bobinages en cuivre et pas d’aimants. Toute sa puissance, sous 800 volts, passe par le convertisseur, qui la remet en forme pour l’adapter au réseau.", - "p4": "Sur l’arbre se trouve un frein à disque. Ce n’est pas lui qui arrête l’éolienne pendant une tempête : ce sont les pales, en présentant leur tranche au vent. Le frein sert seulement à retenir le rotor une fois qu’il a ralenti, pour le garder à l’arrêt. En haut du mât, des moteurs d’orientation tournent toute la nacelle face au vent. Dans ce modèle, elle suit exactement le vent, car la vitesse réelle à laquelle la V150 pivote n’est pas publiée." + "p1": "Le caisson derrière le moyeu, c’est la nacelle, longue de 12,8 m. Choisissez « Sans capot » pour regarder dedans. Elle abrite les machines qui transforment la rotation lente et puissante du rotor en électricité. Toutes reposent sur le châssis, un lourd cadre en fonte. Sur le toit, un refroidisseur évacue leur chaleur.", + "p2": "L’angle que fait une pale avec le vent s’appelle son calage. Il décide de la part du vent que la pale peut saisir. Chaque pale est montée dans le moyeu sur un roulement, ce qui lui permet de pivoter. Trois vérins de calage, un par pale, les font tourner grâce à de l’huile sous pression. Derrière le moyeu, l’arbre principal tourne dans son roulement principal à la vitesse lente du rotor, environ 10 tr/min.", + "p3": "La génératrice a besoin de tourner vite : un multiplicateur, sorte de boîte de vitesses, s’intercale donc entre l’arbre et elle. Il compte deux étages planétaires, où de petits engrenages tournent autour d’un engrenage central, puis un étage hélicoïdal aux dents inclinées. Ensemble, ils multiplient la vitesse par 140 environ, jusqu’à environ 1 485 tr/min. La génératrice est asynchrone : des bobinages en cuivre, pas d’aimants. Elle produit du courant sous 800 volts, et tout ce courant passe par le convertisseur. Le convertisseur le remet en forme pour qu’il suive le rythme du réseau.", + "p4": "Sur l’arbre se trouve un frein à disque. Ce n’est pas lui qui arrête l’éolienne pendant une tempête : ce sont les pales, en présentant leur tranche au vent. Le frein sert seulement à immobiliser le rotor une fois qu’il a ralenti, pour le maintenir à l’arrêt. En haut du mât, des moteurs d’orientation font pivoter toute la nacelle pour qu’elle fasse face au vent. On appelle ce mouvement l’orientation. Dans ce modèle, la nacelle suit exactement le vent, car la vitesse à laquelle la V150 pivote vraiment n’est pas publiée." }, "curve": { "title": "Quelle part du vent une éolienne peut capter", - "p1": "Sous 3 m/s, la vitesse de démarrage, le rotor attend. Au-delà, la puissance grimpe vite, car la puissance du vent croît avec le cube de sa vitesse : vitesse fois vitesse fois vitesse. Quand le vent double, sa puissance est multipliée par huit. L’éolienne produit donc environ 0,3 MW à 4 m/s, environ 2,5 MW à 8 m/s, et la totalité de ses 4,2 MW à partir d’environ 12 m/s. Faites glisser le curseur du vent et regardez le point avancer sur la courbe.", - "p2": "Aucune éolienne ne peut tout capter. Pour prendre de l’énergie, le rotor doit freiner l’air, mais cet air doit quand même pouvoir s’échapper derrière lui. Le physicien Albert Betz a montré que même un rotor parfait ne peut pas prendre plus de 59,3 % de la puissance. C’est la limite de Betz. Cette éolienne en capte au mieux un peu moins de la moitié, vers 7 à 8 m/s, et le graphique montre à quel point elle s’approche de la limite.", - "p3": "Une fois la pleine puissance atteinte, le vent apporte plus que la génératrice ne peut en prendre. Les vérins de calage tournent donc un peu les pales hors du vent, et l’essentiel de l’air passe sans être capté. À 15 m/s, l’éolienne ne garde qu’environ 12 % de la puissance du vent. Les angles de calage affichés à ce stade sont propres à ce modèle, car ceux de la V150 ne sont pas publiés. Au-delà de 20 m/s, la puissance commence à baisser progressivement.", - "p4": "À la vitesse de coupure, environ 22 à 25 m/s, les pales présentent leur tranche au vent. C’est la mise en drapeau : le rotor ralentit tout seul, puis le frein à disque l’immobilise. L’éolienne ne redémarre qu’une fois que le vent a baissé d’environ 2 m/s. Dans ce modèle, les 27 éoliennes s’arrêtent ensemble à 24,5 m/s et repartent à 22,5 m/s. L’arrêt et le redémarrage prennent chacun 20 minutes sur l’horloge, plus qu’en réalité, pour que vous ayez le temps de les observer." + "p1": "Le rotor attend que le vent atteigne 3 m/s, la vitesse de démarrage. Ensuite, la puissance grimpe vite. La puissance du vent croît comme le cube de sa vitesse : vitesse fois vitesse fois vitesse. Si le vent double, il transporte huit fois plus de puissance. L’éolienne produit donc environ 0,3 MW à 4 m/s, environ 2,5 MW à 8 m/s, et la totalité de ses 4,2 MW à partir de 12 m/s environ. Faites glisser le curseur du vent et regardez le point avancer sur la courbe.", + "p2": "Aucune éolienne ne peut capter toute cette puissance. Pour prendre de l’énergie, le rotor doit freiner l’air, mais cet air doit quand même pouvoir s’écouler derrière lui. Si le rotor l’arrêtait complètement, plus aucun air neuf ne pourrait passer. Le physicien Albert Betz a montré que même un rotor parfait ne peut pas prendre plus de 59,3 % de la puissance du vent. C’est la limite de Betz. Au mieux, cette éolienne en capte un peu moins de la moitié, vers 7 à 8 m/s. Le graphique montre à quel point elle s’en approche.", + "p3": "Une fois la pleine puissance atteinte, le vent apporte plus que la génératrice ne peut en prendre. Les vérins de calage tournent donc un peu les pales hors du vent, et l’essentiel de l’air passe sans être capté. À 15 m/s, l’éolienne ne garde qu’environ 12 % de la puissance du vent. Les angles de calage affichés au-delà de la pleine puissance sont propres au modèle, car ceux de la V150 ne sont pas publiés. Passé 20 m/s, l’éolienne commence à réduire sa puissance peu à peu.", + "p4": "À la vitesse de coupure, entre 22 et 25 m/s environ, le vent est trop fort pour que l’éolienne fonctionne. Les pales présentent alors leur tranche au vent. C’est la mise en drapeau : le rotor ralentit tout seul, puis le frein à disque l’immobilise. L’éolienne ne redémarre qu’une fois que le vent a faibli d’environ 2 m/s. Dans ce modèle, les 27 éoliennes s’arrêtent ensemble à 24,5 m/s et repartent à 22,5 m/s. L’arrêt et le redémarrage prennent chacun 20 minutes sur l’horloge, plus longtemps qu’en vrai, pour que vous ayez le temps de les observer." }, "wakes": { "title": "Pourquoi les éoliennes sont si espacées", - "p1": "Une éolienne prend de l’énergie au vent, donc l’air ralentit derrière elle. Cette traînée d’air lent et brassé s’appelle un sillage. Activez « Sillages (K) » pour voir un panache derrière chaque rotor qui tourne. Les sillages sont les plus forts par vent modéré, quand le rotor retient l’air le plus fortement. Une fois la pleine puissance atteinte, les pales laissent filer davantage de vent, et les sillages s’estompent.", - "p2": "Ce modèle dessine les sillages avec une recette simple et très répandue, le modèle de Jensen. Il traite chaque sillage comme un cône qui s’élargit et se remplit peu à peu d’air plus rapide. La vitesse à laquelle il s’élargit dépend d’un seul nombre, ici 0,075, la valeur habituelle pour les parcs à terre. À 8 m/s, une éolienne placée 7 D plus loin sous le vent reçoit encore un air environ 13 % plus lent. Comme la puissance suit le cube du vent, cela lui coûte environ un tiers de sa puissance.", - "p3": "Sur une année, les sillages font perdre à un parc environ 5 à 15 % de son énergie. Dans un exemple de parc fourni par Vestas, avec 24 de ces éoliennes, la perte est d’environ 6 %. Pour une seule direction de vent, elle peut dépasser 30 %. C’est pourquoi les rangées sont en général espacées de 5 à 9 D dans l’axe du vent, et de 3 à 5 D en travers. Essayez 5, 7 et 9 D : des écarts plus larges perdent moins de vent, mais demandent plus de terrain.", - "p4": "Le vent forcit aussi avec la hauteur, car le sol freine l’air qui le touche. Activez « Flux d’air (F) » pour voir ce profil : le haut du rotor reçoit un vent plus rapide que le bas. Les panaches et les flèches ne sont que des dessins, car l’air, lui, est invisible. Dans ce modèle, le sol est plat, un rotor arrêté ne laisse pas de sillage, et les sillages qui se chevauchent s’additionnent de façon simple." + "p1": "Une éolienne prend de l’énergie au vent : derrière elle, l’air avance donc plus lentement. Cette traînée d’air lent et brassé s’appelle un sillage, comme celui que laisse un bateau. Activez « Sillages (K) » pour voir un panache derrière chaque rotor qui tourne. Les sillages sont les plus marqués par vent modéré, quand le rotor freine le plus l’air. Une fois la pleine puissance atteinte, les pales laissent filer davantage de vent, et les sillages s’estompent.", + "p2": "Les sillages sont dessinés ici avec une recette simple et très répandue, le modèle de Jensen. Il traite chaque sillage comme un cône qui s’élargit et se remplit peu à peu d’air plus rapide. Un seul nombre règle la vitesse à laquelle il s’élargit : ici 0,075, la valeur habituelle pour l’éolien terrestre. À 8 m/s, une éolienne placée 7 D plus loin sous le vent reçoit encore un air environ 13 % plus lent. Comme la puissance suit le cube de la vitesse, cela lui coûte environ un tiers de sa puissance.", + "p3": "Sur une année, les sillages font perdre à un parc environ 5 à 15 % de son énergie. Dans un parc type décrit par Vestas, avec 24 de ces éoliennes, la perte est d’environ 6 %. Quand le vent souffle d’une direction particulièrement défavorable, elle peut dépasser 30 %. C’est pourquoi les rangées sont en général espacées de 5 à 9 D dans l’axe du vent, et les éoliennes de 3 à 5 D en travers. Essayez 5 D, 7 D et 9 D : avec des écarts plus grands, on perd moins à cause des sillages, mais il faut plus de terrain.", + "p4": "Le vent souffle aussi plus fort en hauteur, car le sol freine l’air qui l’effleure. Activez « Flux d’air (F) » pour le voir : le haut du rotor reçoit un vent plus rapide que le bas. Les panaches et les flèches ne sont que des dessins. Dans ce modèle, le sol est plat, un rotor arrêté ne laisse pas de sillage, et les sillages qui se chevauchent s’additionnent de façon simple." }, "grid": { "title": "Le chemin jusqu’au réseau", - "p1": "Chaque génératrice produit du courant sous 800 volts (V). Après le convertisseur, le transformateur de l’éolienne élève la tension. Une tension plus haute transporte la même puissance avec moins de courant, donc les câbles perdent moins d’énergie en chaleur. Activez « Câbles (C) » pour suivre le courant des éoliennes jusqu’au réseau.", - "p2": "Des câbles de collecte à 36 000 volts, soit 36 kilovolts (kV), relient les éoliennes au poste électrique. Là, un autre transformateur élève la tension à 110 kV pour la ligne du réseau. Ces tensions viennent d’un exemple de parc en Allemagne, fourni par Vestas.", - "p3": "À pleine puissance, une éolienne produit 4,2 mégawattheures (MWh) chaque heure. Mais le vent n’est pas toujours fort : sur un an, une éolienne fournit en moyenne environ 40 % de sa pleine puissance. Cette part s’appelle le facteur de charge. En un an, une éolienne produit environ 14 700 MWh, assez pour environ 1 400 foyers américains. La journée inventée du modèle est plus venteuse que la moyenne de l’année, si bien que son facteur de charge est plus élevé.", - "p4": "Fabriquer une éolienne coûte aussi de l’énergie et du carbone : sur toute sa durée de vie, elle représente environ 7 g de CO2e par kilowattheure (kWh) produit. Le CO2e compte tous les gaz à effet de serre comme s’ils étaient du dioxyde de carbone. L’éolienne rembourse en environ 8 mois l’énergie qui a servi à la construire. Elle est conçue pour 20 ans et tourne souvent plus longtemps, même si ses pales restent difficiles à recycler. Choisissez un site calme, moyen ou venté pour voir à quel point le vent de tous les jours compte." + "p1": "Chaque génératrice produit de l’électricité sous 800 volts (V). Après le convertisseur, le transformateur propre à l’éolienne élève la tension. Sous une tension plus haute, la même puissance demande moins de courant, et les câbles en gaspillent donc moins sous forme de chaleur. Activez « Câbles (C) » pour suivre l’électricité des éoliennes jusqu’au réseau.", + "p2": "Des câbles de collecte à 36 000 volts, soit 36 kilovolts (kV), relient les éoliennes au poste électrique. Là, un autre transformateur relève encore la tension, jusqu’à 110 kV, pour la ligne du réseau. Ces tensions viennent d’un parc type décrit par Vestas, en Allemagne.", + "p3": "À pleine puissance, une éolienne produit 4,2 mégawattheures (MWh) chaque heure. Mais le vent n’est pas toujours fort. Sur un an, une éolienne fournit en moyenne environ 40 % de sa pleine puissance. Cette part s’appelle son facteur de charge. Une éolienne produit environ 14 700 MWh par an. C’est de quoi alimenter environ 1 400 foyers américains. La journée inventée du modèle est plus venteuse qu’une journée moyenne : son facteur de charge est donc plus élevé.", + "p4": "Construire une éolienne coûte aussi de l’énergie et du carbone. Sur toute sa durée de vie, elle représente environ 7 g de CO2e pour chaque kilowattheure (kWh) produit. Le CO2e compte tous les gaz à effet de serre comme s’ils étaient du dioxyde de carbone. En 8 mois environ, une éolienne a rendu l’énergie qu’il a fallu pour la construire. Elle est conçue pour durer 20 ans et tourne souvent plus longtemps, même si ses pales restent difficiles à recycler. Choisissez un site calme, moyen ou venté pour voir à quel point le vent de tous les jours compte." } } } From 9561b67b560ad5207527587e61c6573b42d6bc5c Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:40:07 +0200 Subject: [PATCH 038/132] feat(wind-farm): use the writer's social image alt text --- explainers/wind-farm/explainer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/explainers/wind-farm/explainer.json b/explainers/wind-farm/explainer.json index d5ca2dd1..4c989605 100644 --- a/explainers/wind-farm/explainer.json +++ b/explainers/wind-farm/explainer.json @@ -7,6 +7,6 @@ "locales": ["en", "zh", "es", "uk", "pt", "fr", "de", "ja"], "social": { "image": "social/og-image.png", - "alt": "A wind farm of white turbines on green fields under a blue sky, beside the title How a wind farm works" + "alt": "A wind farm on rolling land with one turbine close up, beside the title How a wind farm works" } } From 36103fbcb3061169165dd490dafafb519602b968 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:51:28 +0200 Subject: [PATCH 039/132] fix(wind-farm): halve the daily wind veer so wider spacing always loses less to wakes --- explainers/wind-farm/src/model/constants.ts | 2 +- explainers/wind-farm/src/model/day.test.ts | 6 +++--- explainers/wind-farm/src/model/energy.test.ts | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/explainers/wind-farm/src/model/constants.ts b/explainers/wind-farm/src/model/constants.ts index 7d124190..355bfeca 100644 --- a/explainers/wind-farm/src/model/constants.ts +++ b/explainers/wind-farm/src/model/constants.ts @@ -45,4 +45,4 @@ export const PROJECT_HA_PER_MW = 34; export const FOOTBALL_PITCH_M2 = 7140; export const WIND_FROM_DEG = 270; -export const VEER_DEG = 6; +export const VEER_DEG = 3; diff --git a/explainers/wind-farm/src/model/day.test.ts b/explainers/wind-farm/src/model/day.test.ts index 85302c04..d27555ec 100644 --- a/explainers/wind-farm/src/model/day.test.ts +++ b/explainers/wind-farm/src/model/day.test.ts @@ -68,10 +68,10 @@ describe('dayWind', () => { }); describe('windFromDeg', () => { - it('veers 6 degrees either side of west through the day', () => { + it('veers 3 degrees either side of west through the day', () => { expect(windFromDeg(0)).toBeCloseTo(270, 9); - expect(windFromDeg(360)).toBeCloseTo(276, 9); - expect(windFromDeg(1080)).toBeCloseTo(264, 9); + expect(windFromDeg(360)).toBeCloseTo(273, 9); + expect(windFromDeg(1080)).toBeCloseTo(267, 9); }); }); diff --git a/explainers/wind-farm/src/model/energy.test.ts b/explainers/wind-farm/src/model/energy.test.ts index d63cf67d..6b2c7abc 100644 --- a/explainers/wind-farm/src/model/energy.test.ts +++ b/explainers/wind-farm/src/model/energy.test.ts @@ -57,7 +57,7 @@ describe('energy today', () => { .slice(1) .forEach((total, index) => expect(total).toBeGreaterThanOrEqual(samples[index])); expect(energyTodayMwh(0, 'typical', 7)).toBe(0); - expect(dayEnergyMwh('typical', 7)).toBeCloseTo(1182.8, 1); + expect(dayEnergyMwh('typical', 7)).toBeCloseTo(1183.3, 1); }); it('reads linearly between the samples', () => { From 4a9ddecd145e2dd59467c421587fb4b7888a1d37 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:53:13 +0200 Subject: [PATCH 040/132] fix(wind-farm): open the farm chapter with its own view on first load --- explainers/wind-farm/src/state/store.test.ts | 21 +++++++++++++++----- explainers/wind-farm/src/state/store.ts | 3 ++- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/explainers/wind-farm/src/state/store.test.ts b/explainers/wind-farm/src/state/store.test.ts index d3f293e8..56b4724b 100644 --- a/explainers/wind-farm/src/state/store.test.ts +++ b/explainers/wind-farm/src/state/store.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_SITE_WIND, DEFAULT_VIEW, START_PHASE, + START_VIEW, WIND_OVERRIDE_RANGE, createWindFarmStore, snapToRange, @@ -12,7 +13,7 @@ import { import { PRESETS } from './presets'; describe('wind farm store', () => { - it('starts on the farm chapter at noon on a typical site with every view shown', () => { + it('starts on the farm chapter at noon on a typical site with the farm view', () => { const state = createWindFarmStore().getState(); expect(state.preset).toBe('farm'); expect(state.phase).toBe(START_PHASE); @@ -20,15 +21,25 @@ describe('wind farm store', () => { expect(state.siteWind).toBe(DEFAULT_SITE_WIND); expect(state.spacing).toBe(DEFAULT_SPACING_D); expect(state.windOverride).toBeNull(); - expect(DEFAULT_VIEW).toEqual({ - streamlines: true, - wakes: true, - cables: true, + expect(state.view).toEqual(START_VIEW); + expect(START_VIEW).toEqual({ ...DEFAULT_VIEW, ...PRESETS.farm.view }); + expect(state.view).toEqual({ + streamlines: false, + wakes: false, + cables: false, labels: true, cutaway: false, }); }); + it('shows the same farm view on coming back to the farm chapter', () => { + const store = createWindFarmStore(); + const firstView = store.getState().view; + store.getState().applyPreset('wakes'); + store.getState().applyPreset('farm'); + expect(store.getState().view).toEqual(firstView); + }); + it('snaps the wind override to half metres per second inside its range', () => { const store = createWindFarmStore(); store.getState().setWindOverride(7.3); diff --git a/explainers/wind-farm/src/state/store.ts b/explainers/wind-farm/src/state/store.ts index c57c3976..b71ac8c1 100644 --- a/explainers/wind-farm/src/state/store.ts +++ b/explainers/wind-farm/src/state/store.ts @@ -50,6 +50,7 @@ export const CHAPTER_CONTROL_DEFAULTS: ChapterControls = { const START_PRESET: PresetId = 'farm'; const START_SPEED = PRESETS[START_PRESET].speed ?? SPEED_RANGE.default; export const START_PHASE = PRESETS[START_PRESET].startAt ?? 0; +export const START_VIEW: ViewState = { ...DEFAULT_VIEW, ...PRESETS[START_PRESET].view }; export function snapToRange(value: number, { min, max, step }: SteppedRange): number { return clamp(min + Math.round((value - min) / step) * step, min, max); @@ -69,7 +70,7 @@ export function createWindFarmStore(overrides: Partial = {}) { timeline: WIND_FARM_TIMELINE, presets: PRESETS, - defaults: { preset: START_PRESET, speed: START_SPEED, view: DEFAULT_VIEW }, + defaults: { preset: START_PRESET, speed: START_SPEED, view: START_VIEW }, extend: (set) => ({ ...CHAPTER_CONTROL_DEFAULTS, ...PRESETS[START_PRESET].start, From fedd509ebea12c387414074b8f9be0b92e13a87b Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:54:34 +0200 Subject: [PATCH 041/132] fix(wind-farm): put the power curve dot at the hero's live power --- .../src/ui/powerCurveView.dom.test.ts | 40 ++++++++++++++----- explainers/wind-farm/src/ui/powerCurveView.ts | 40 +++++++++++++------ 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/explainers/wind-farm/src/ui/powerCurveView.dom.test.ts b/explainers/wind-farm/src/ui/powerCurveView.dom.test.ts index 35bec4c8..868406db 100644 --- a/explainers/wind-farm/src/ui/powerCurveView.dom.test.ts +++ b/explainers/wind-farm/src/ui/powerCurveView.dom.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { CanvasSurface } from '@core/ui/canvasSurface'; import type { CanvasFrame } from '@core/ui/canvasSurface'; -import { RATED_KW, RATED_WIND_MS } from '../model'; -import { createWindFarmStore } from '../state'; +import { RATED_KW, RATED_WIND_MS, parkedWindow, turbinePowerKw } from '../model'; +import { createWindFarmStore, liveReading } from '../state'; import { AXIS_POWER_KW, AXIS_WIND_MS, @@ -13,11 +13,13 @@ import { xOfWind, yOfPower, } from './powerCurveView'; +import type { CurvePoint } from './powerCurveView'; import { TEST_LOCALE, initTestLocale } from './testing'; const FRAME: CanvasFrame = { width: 600, height: 300, ratio: 2, fontFamily: 'sans-serif' }; const CHAR_WIDTH = 6; const { curve } = TEST_LOCALE.chapters; +const RATED_POINT: CurvePoint = { wind: RATED_WIND_MS, powerKw: RATED_KW }; interface Arc { x: number; @@ -67,20 +69,38 @@ describe('power curve', () => { it('labels both axes and all three lines', () => { const { context, texts } = recordingContext(); - paintPowerCurve(context, FRAME, RATED_WIND_MS); + paintPowerCurve(context, FRAME, RATED_POINT); [curve.axisWind, curve.axisPower, curve.lineWind, curve.lineBetz, curve.lineTurbine].forEach( (label) => expect(texts).toContain(label), ); }); - it('puts the dot on the turbine curve at the live wind', () => { + it('puts the dot at the live wind and power', () => { const { context, arcs } = recordingContext(); - paintPowerCurve(context, FRAME, RATED_WIND_MS); + paintPowerCurve(context, FRAME, RATED_POINT); const plot = plotOf(FRAME); expect(arcs).toEqual([{ x: xOfWind(plot, RATED_WIND_MS), y: yOfPower(plot, RATED_KW) }]); }); - it('repaints only when the live wind changes', () => { + it('drops the dot to zero while the hero starts up after the storm', () => { + const { context, arcs } = recordingContext(); + vi.spyOn(CanvasSurface.prototype, 'paint').mockImplementation((painter) => + painter(context, FRAME), + ); + document.body.innerHTML = ''; + const restart = parkedWindow('typical')?.restart ?? 0; + const store = createWindFarmStore({ playing: false, phase: restart }); + const { wind, heroKw, operating } = liveReading(store.getState()); + expect(operating.state).toBe('starting'); + expect(turbinePowerKw(wind)).toBeGreaterThan(0); + expect(heroKw).toBe(0); + const dispose = mountPowerCurve(document, store); + const plot = plotOf(FRAME); + expect(arcs).toEqual([{ x: xOfWind(plot, wind), y: yOfPower(plot, 0) }]); + dispose(); + }); + + it('repaints only when the live wind or the hero power changes', () => { const paint = vi.spyOn(CanvasSurface.prototype, 'paint'); document.body.innerHTML = ''; const store = createWindFarmStore({ playing: false, windOverride: RATED_WIND_MS }); @@ -93,12 +113,14 @@ describe('power curve', () => { dispose(); }); - it('skips a repaint for the same wind and language', () => { + it('skips a repaint for the same point and language', () => { const paint = vi.spyOn(CanvasSurface.prototype, 'paint'); const view = new PowerCurveView(document.createElement('canvas')); - view.draw(RATED_WIND_MS); - view.draw(RATED_WIND_MS); + view.draw(RATED_POINT); + view.draw({ ...RATED_POINT }); expect(paint).toHaveBeenCalledTimes(1); + view.draw({ ...RATED_POINT, powerKw: 0 }); + expect(paint).toHaveBeenCalledTimes(2); view.dispose(); }); }); diff --git a/explainers/wind-farm/src/ui/powerCurveView.ts b/explainers/wind-farm/src/ui/powerCurveView.ts index 35586675..b0ddd7f5 100644 --- a/explainers/wind-farm/src/ui/powerCurveView.ts +++ b/explainers/wind-farm/src/ui/powerCurveView.ts @@ -5,9 +5,9 @@ import { CanvasSurface, canvasFont } from '@core/ui/canvasSurface'; import type { CanvasFrame } from '@core/ui/canvasSurface'; import type { Disposer } from '@core/ui/disposers'; import { requireElement } from '@core/ui/dom'; -import { watchLocalized } from '@core/ui/subscribe'; +import { watchShallowLocalized } from '@core/ui/subscribe'; import { BETZ_LIMIT, turbinePowerKw, windPowerKw } from '../model'; -import { WIND_OVERRIDE_RANGE, liveWind } from '../state'; +import { WIND_OVERRIDE_RANGE, liveReading } from '../state'; import type { WindFarmStore } from '../state'; import { THEME } from '../theme'; @@ -60,6 +60,11 @@ const LINES: readonly Line[] = [ { key: 'chapters.curve.lineTurbine', color: CURVE_COLORS.turbine, powerKw: turbinePowerKw }, ]; +export interface CurvePoint { + wind: number; + powerKw: number; +} + export interface Plot { left: number; right: number; @@ -159,9 +164,9 @@ function paintLegend(context: CanvasRenderingContext2D, frame: CanvasFrame, plot }); } -function paintDot(context: CanvasRenderingContext2D, plot: Plot, wind: number): void { +function paintDot(context: CanvasRenderingContext2D, plot: Plot, point: CurvePoint): void { context.beginPath(); - context.arc(xOfWind(plot, wind), yOfPower(plot, turbinePowerKw(wind)), DOT_RADIUS, 0, FULL_TURN); + context.arc(xOfWind(plot, point.wind), yOfPower(plot, point.powerKw), DOT_RADIUS, 0, FULL_TURN); context.fillStyle = CURVE_COLORS.turbine; context.fill(); context.strokeStyle = CURVE_COLORS.dotRing; @@ -172,31 +177,35 @@ function paintDot(context: CanvasRenderingContext2D, plot: Plot, wind: number): export function paintPowerCurve( context: CanvasRenderingContext2D, frame: CanvasFrame, - wind: number, + point: CurvePoint, ): void { const plot = plotOf(frame); paintGrid(context, frame, plot); paintAxisLabels(context, frame, plot); LINES.forEach((line) => paintLine(context, plot, line)); paintLegend(context, frame, plot); - paintDot(context, plot, wind); + paintDot(context, plot, point); } export class PowerCurveView { private readonly surface: CanvasSurface; - private paintedWind: number | null = null; + private painted: CurvePoint | null = null; private language = ''; constructor(canvas: HTMLCanvasElement) { this.surface = new CanvasSurface(canvas); } - draw(wind: number): void { + draw(point: CurvePoint): void { const language = currentLanguage(); - if (wind === this.paintedWind && language === this.language) return; - this.paintedWind = wind; + if (this.isPainted(point) && language === this.language) return; + this.painted = { ...point }; this.language = language; - this.surface.paint((context, frame) => paintPowerCurve(context, frame, wind)); + this.surface.paint((context, frame) => paintPowerCurve(context, frame, point)); + } + + private isPainted({ wind, powerKw }: CurvePoint): boolean { + return this.painted?.wind === wind && this.painted.powerKw === powerKw; } dispose(): void { @@ -206,7 +215,14 @@ export class PowerCurveView { export function mountPowerCurve(root: Document, store: WindFarmStore): Disposer { const view = new PowerCurveView(requireElement(root, POWER_CURVE_CANVAS)); - const stopWatching = watchLocalized(store, liveWind, (wind) => view.draw(wind)); + const stopWatching = watchShallowLocalized( + store, + (state) => { + const { wind, heroKw } = liveReading(state); + return [wind, heroKw] as const; + }, + ([wind, powerKw]) => view.draw({ wind, powerKw }), + ); return () => { stopWatching(); view.dispose(); From 1dc70e3ed80a01158b8ea779659381528d306d6d Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:57:04 +0200 Subject: [PATCH 042/132] fix(wind-farm): tell the power curve lines apart by dash and label the chart --- .../src/ui/powerCurveView.dom.test.ts | 48 +++++++++++++++++-- explainers/wind-farm/src/ui/powerCurveView.ts | 31 +++++++++++- explainers/wind-farm/src/ui/testing.ts | 1 + 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/explainers/wind-farm/src/ui/powerCurveView.dom.test.ts b/explainers/wind-farm/src/ui/powerCurveView.dom.test.ts index 868406db..65528b2e 100644 --- a/explainers/wind-farm/src/ui/powerCurveView.dom.test.ts +++ b/explainers/wind-farm/src/ui/powerCurveView.dom.test.ts @@ -6,6 +6,8 @@ import { createWindFarmStore, liveReading } from '../state'; import { AXIS_POWER_KW, AXIS_WIND_MS, + CURVE_COLORS, + CURVE_DASHES, PowerCurveView, mountPowerCurve, paintPowerCurve, @@ -26,9 +28,17 @@ interface Arc { y: number; } +interface Stroke { + color: string; + dash: readonly number[]; +} + function recordingContext() { const texts: string[] = []; const arcs: Arc[] = []; + const strokes: Stroke[] = []; + const savedDashes: (readonly number[])[] = []; + let dash: readonly number[] = []; const noop = () => {}; const context = { font: '', @@ -40,17 +50,22 @@ function recordingContext() { measureText: (text: string) => ({ width: text.length * CHAR_WIDTH }), fillText: (text: string) => texts.push(text), arc: (x: number, y: number) => arcs.push({ x, y }), - save: noop, - restore: noop, + setLineDash: (segments: number[]) => { + dash = [...segments]; + }, + save: () => savedDashes.push(dash), + restore: () => { + dash = savedDashes.pop() ?? []; + }, rect: noop, clip: noop, beginPath: noop, moveTo: noop, lineTo: noop, - stroke: noop, + stroke: () => strokes.push({ color: context.strokeStyle, dash }), fill: noop, }; - return { context: context as unknown as CanvasRenderingContext2D, texts, arcs }; + return { context: context as unknown as CanvasRenderingContext2D, texts, arcs, strokes }; } describe('power curve', () => { @@ -75,6 +90,31 @@ describe('power curve', () => { ); }); + it('dashes the wind and Betz lines and their legend swatches apart from the turbine line', () => { + const { context, strokes } = recordingContext(); + paintPowerCurve(context, FRAME, RATED_POINT); + const lines = ['wind', 'betz', 'turbine'] as const; + lines.forEach((line) => { + const dashes = strokes.filter(({ color }) => color === CURVE_COLORS[line]); + expect(dashes).toEqual([ + { color: CURVE_COLORS[line], dash: CURVE_DASHES[line] }, + { color: CURVE_COLORS[line], dash: CURVE_DASHES[line] }, + ]); + }); + const patterns = new Set(lines.map((line) => CURVE_DASHES[line].join())); + expect(patterns.size).toBe(lines.length); + expect(strokes.at(-1)).toEqual({ color: CURVE_COLORS.dotRing, dash: [] }); + }); + + it('labels the canvas as an image with the caption', () => { + document.body.innerHTML = ''; + const dispose = mountPowerCurve(document, createWindFarmStore({ playing: false })); + const canvas = document.querySelector('canvas'); + expect(canvas?.getAttribute('role')).toBe('img'); + expect(canvas?.getAttribute('aria-label')).toBe(curve.caption); + dispose(); + }); + it('puts the dot at the live wind and power', () => { const { context, arcs } = recordingContext(); paintPowerCurve(context, FRAME, RATED_POINT); diff --git a/explainers/wind-farm/src/ui/powerCurveView.ts b/explainers/wind-farm/src/ui/powerCurveView.ts index b0ddd7f5..c1521cb8 100644 --- a/explainers/wind-farm/src/ui/powerCurveView.ts +++ b/explainers/wind-farm/src/ui/powerCurveView.ts @@ -33,6 +33,7 @@ const DOT_RADIUS = 4.5; const DOT_RING = 1.5; const GRID_ALPHA = 0.14; const POWER_CURVE_CANVAS = '[data-canvas="power-curve"]'; +const CAPTION_KEY = 'chapters.curve.caption'; export const CURVE_COLORS = { grid: withAlpha(THEME.muted, GRID_ALPHA), @@ -44,20 +45,38 @@ export const CURVE_COLORS = { dotRing: THEME.text, } as const; +export const CURVE_DASHES: Readonly> = { + wind: [8, 4], + betz: [2, 3], + turbine: [], +}; + interface Line { key: string; color: string; + dash: readonly number[]; powerKw: (wind: number) => number; } const LINES: readonly Line[] = [ - { key: 'chapters.curve.lineWind', color: CURVE_COLORS.wind, powerKw: windPowerKw }, + { + key: 'chapters.curve.lineWind', + color: CURVE_COLORS.wind, + dash: CURVE_DASHES.wind, + powerKw: windPowerKw, + }, { key: 'chapters.curve.lineBetz', color: CURVE_COLORS.betz, + dash: CURVE_DASHES.betz, powerKw: (wind) => BETZ_LIMIT * windPowerKw(wind), }, - { key: 'chapters.curve.lineTurbine', color: CURVE_COLORS.turbine, powerKw: turbinePowerKw }, + { + key: 'chapters.curve.lineTurbine', + color: CURVE_COLORS.turbine, + dash: CURVE_DASHES.turbine, + powerKw: turbinePowerKw, + }, ]; export interface CurvePoint { @@ -134,6 +153,7 @@ function paintLine(context: CanvasRenderingContext2D, plot: Plot, line: Line): v context.clip(); context.strokeStyle = line.color; context.lineWidth = CURVE_WIDTH; + context.setLineDash(line.dash); context.beginPath(); ticks(AXIS_WIND_MS, SAMPLE_STEP_MS).forEach((wind, index) => { const x = xOfWind(plot, wind); @@ -146,6 +166,7 @@ function paintLine(context: CanvasRenderingContext2D, plot: Plot, line: Line): v } function paintLegend(context: CanvasRenderingContext2D, frame: CanvasFrame, plot: Plot): void { + context.save(); context.font = canvasFont(frame, LABEL_FONT_PX); context.textBaseline = 'middle'; context.textAlign = 'right'; @@ -157,11 +178,13 @@ function paintLegend(context: CanvasRenderingContext2D, frame: CanvasFrame, plot context.fillStyle = CURVE_COLORS.label; context.fillText(t(line.key), textRight, y); context.strokeStyle = line.color; + context.setLineDash(line.dash); context.beginPath(); context.moveTo(lineRight - LEGEND_LINE, y); context.lineTo(lineRight, y); context.stroke(); }); + context.restore(); } function paintDot(context: CanvasRenderingContext2D, plot: Plot, point: CurvePoint): void { @@ -188,17 +211,21 @@ export function paintPowerCurve( } export class PowerCurveView { + private readonly canvas: HTMLCanvasElement; private readonly surface: CanvasSurface; private painted: CurvePoint | null = null; private language = ''; constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; this.surface = new CanvasSurface(canvas); + canvas.setAttribute('role', 'img'); } draw(point: CurvePoint): void { const language = currentLanguage(); if (this.isPainted(point) && language === this.language) return; + if (language !== this.language) this.canvas.setAttribute('aria-label', t(CAPTION_KEY)); this.painted = { ...point }; this.language = language; this.surface.paint((context, frame) => paintPowerCurve(context, frame, point)); diff --git a/explainers/wind-farm/src/ui/testing.ts b/explainers/wind-farm/src/ui/testing.ts index 24bbc840..f24f99ed 100644 --- a/explainers/wind-farm/src/ui/testing.ts +++ b/explainers/wind-farm/src/ui/testing.ts @@ -45,6 +45,7 @@ export const TEST_LOCALE = { lineWind: 'Power in the wind', lineBetz: 'Betz limit', lineTurbine: 'This turbine', + caption: 'Power in the wind, the Betz limit and this turbine', }, grid: { voltagesValue: '{{generator}} V, {{collector}} kV, {{grid}} kV' }, }, From 530dcb7ec157b8f35cd1cfe121d4d54d5db5297a Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:58:17 +0200 Subject: [PATCH 043/132] fix(wind-farm): scope each wind slider to its own range widget --- .../src/ui/mountWindOverride.dom.test.ts | 42 +++++++++++++++++++ .../wind-farm/src/ui/mountWindOverride.ts | 11 ++++- 2 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 explainers/wind-farm/src/ui/mountWindOverride.dom.test.ts diff --git a/explainers/wind-farm/src/ui/mountWindOverride.dom.test.ts b/explainers/wind-farm/src/ui/mountWindOverride.dom.test.ts new file mode 100644 index 00000000..21e1b24e --- /dev/null +++ b/explainers/wind-farm/src/ui/mountWindOverride.dom.test.ts @@ -0,0 +1,42 @@ +import { beforeAll, describe, expect, it } from 'vitest'; +import { RATED_WIND_MS } from '../model'; +import { createWindFarmStore } from '../state'; +import { mountWindOverride } from './mountWindOverride'; +import { initTestLocale } from './testing'; + +const FIRST_ID = 'tower-wind'; +const SECOND_ID = 'curve-wind'; + +function slider(id: string): string { + return ``; +} + +function widget(id: string, inner: string): string { + return `
${inner}
`; +} + +describe('wind override slider', () => { + beforeAll(() => initTestLocale()); + + it('drives its own widget when the markup wraps the slider', () => { + document.body.innerHTML = + widget(FIRST_ID, slider(FIRST_ID)) + widget(SECOND_ID, `
${slider(SECOND_ID)}
`); + const store = createWindFarmStore({ playing: false }); + const dispose = mountWindOverride(document, store, SECOND_ID); + const input = document.querySelector(`#${SECOND_ID}`); + if (!input) throw new Error('Missing slider'); + input.value = String(RATED_WIND_MS); + input.dispatchEvent(new Event('input')); + expect(store.getState().windOverride).toBe(RATED_WIND_MS); + expect(document.querySelector(`output[for="${SECOND_ID}"]`)?.textContent).not.toBe(''); + expect(document.querySelector(`output[for="${FIRST_ID}"]`)?.textContent).toBe(''); + dispose(); + }); + + it('refuses a slider outside a range widget instead of binding another one', () => { + document.body.innerHTML = widget(FIRST_ID, slider(FIRST_ID)) + slider(SECOND_ID); + expect(() => mountWindOverride(document, createWindFarmStore(), SECOND_ID)).toThrow( + `#${SECOND_ID}`, + ); + }); +}); diff --git a/explainers/wind-farm/src/ui/mountWindOverride.ts b/explainers/wind-farm/src/ui/mountWindOverride.ts index d9826fab..156b7dff 100644 --- a/explainers/wind-farm/src/ui/mountWindOverride.ts +++ b/explainers/wind-farm/src/ui/mountWindOverride.ts @@ -6,12 +6,19 @@ import type { WindFarmStore } from '../state'; import { formatWind } from './format'; const WIND_OVERRIDE_CONTROL = 'wind-override'; +const RANGE_WIDGET = '.range-widget'; type SelectedWind = readonly [wind: number]; -export function mountWindOverride(root: Document, store: WindFarmStore, inputId: string): Disposer { +function widgetAround(root: Document, inputId: string): HTMLElement { const input = requireElement(root, `#${inputId}`); - return mountRangeWidget(input.parentElement ?? root, store, { + const widget = input.closest(RANGE_WIDGET); + if (!widget) throw new Error(`Missing ${RANGE_WIDGET} around #${inputId}`); + return widget; +} + +export function mountWindOverride(root: Document, store: WindFarmStore, inputId: string): Disposer { + return mountRangeWidget(widgetAround(root, inputId), store, { control: WIND_OVERRIDE_CONTROL, range: WIND_OVERRIDE_RANGE, select: (state): SelectedWind => [liveWind(state)], From 90f799c2d10854160a63deb8b4d2c063063adf4d Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:59:09 +0200 Subject: [PATCH 044/132] refactor(wind-farm): key the chapter actions by their ids and parse spacing as an option --- explainers/wind-farm/src/ui/actions.test.ts | 34 +++++++++------------ explainers/wind-farm/src/ui/actions.ts | 10 +++--- 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/explainers/wind-farm/src/ui/actions.test.ts b/explainers/wind-farm/src/ui/actions.test.ts index 9f70754c..ee737881 100644 --- a/explainers/wind-farm/src/ui/actions.test.ts +++ b/explainers/wind-farm/src/ui/actions.test.ts @@ -6,12 +6,6 @@ import { CHAPTER_ACTIONS } from './actions'; const OFF_PRESET_WIND = 9.5; -function action(id: string) { - const found = CHAPTER_ACTIONS[id]; - if (!found) throw new Error(`No action ${id}`); - return found; -} - describe('chapter actions', () => { it('covers every chapter action', () => { expect(Object.keys(CHAPTER_ACTIONS)).toEqual([...CHAPTER_ACTION_IDS]); @@ -20,47 +14,47 @@ describe('chapter actions', () => { it('switches the site wind', () => { const store = createWindFarmStore(); SITE_WIND_IDS.forEach((site) => { - action('siteWind').run(store.getState(), site); + CHAPTER_ACTIONS.siteWind.run(store.getState(), site); expect(store.getState().siteWind).toBe(site); - expect(action('siteWind').current?.(store.getState())).toBe(site); + expect(CHAPTER_ACTIONS.siteWind.current?.(store.getState())).toBe(site); }); }); it('switches the row spacing', () => { const store = createWindFarmStore(); SPACING_CHOICE_IDS.forEach((choice) => { - action('spacing').run(store.getState(), choice); + CHAPTER_ACTIONS.spacing.run(store.getState(), choice); expect(store.getState().spacing).toBe(Number(choice)); - expect(action('spacing').current?.(store.getState())).toBe(choice); + expect(CHAPTER_ACTIONS.spacing.current?.(store.getState())).toBe(choice); }); - expect(() => action('spacing').run(store.getState(), '6')).toThrow(); + expect(() => CHAPTER_ACTIONS.spacing.run(store.getState(), '6')).toThrow(); }); it('opens and closes the nacelle', () => { const store = createWindFarmStore(); - action('nacelle').run(store.getState(), 'open'); + CHAPTER_ACTIONS.nacelle.run(store.getState(), 'open'); expect(store.getState().view.cutaway).toBe(true); - expect(action('nacelle').current?.(store.getState())).toBe('open'); - action('nacelle').run(store.getState(), 'closed'); + expect(CHAPTER_ACTIONS.nacelle.current?.(store.getState())).toBe('open'); + CHAPTER_ACTIONS.nacelle.run(store.getState(), 'closed'); expect(store.getState().view.cutaway).toBe(false); - expect(action('nacelle').current?.(store.getState())).toBe('closed'); + expect(CHAPTER_ACTIONS.nacelle.current?.(store.getState())).toBe('closed'); }); it('pins the wind to a preset or hands it back to the day', () => { const store = createWindFarmStore(); WIND_PRESET_IDS.forEach((preset) => { - action('windAt').run(store.getState(), preset); + CHAPTER_ACTIONS.windAt.run(store.getState(), preset); expect(store.getState().windOverride).toBe(WIND_PRESETS[preset]); - expect(action('windAt').current?.(store.getState())).toBe(preset); + expect(CHAPTER_ACTIONS.windAt.current?.(store.getState())).toBe(preset); }); - action('windAt').run(store.getState(), 'day'); + CHAPTER_ACTIONS.windAt.run(store.getState(), 'day'); expect(store.getState().windOverride).toBeNull(); - expect(action('windAt').current?.(store.getState())).toBe('day'); + expect(CHAPTER_ACTIONS.windAt.current?.(store.getState())).toBe('day'); }); it('marks no wind chip for a wind that matches no preset', () => { const store = createWindFarmStore(); store.getState().setWindOverride(OFF_PRESET_WIND); - expect(action('windAt').current?.(store.getState())).toBe(''); + expect(CHAPTER_ACTIONS.windAt.current?.(store.getState())).toBe(''); }); }); diff --git a/explainers/wind-farm/src/ui/actions.ts b/explainers/wind-farm/src/ui/actions.ts index cd1c6a19..d373dc7a 100644 --- a/explainers/wind-farm/src/ui/actions.ts +++ b/explainers/wind-farm/src/ui/actions.ts @@ -4,11 +4,11 @@ import { FOLLOW_DAY, NACELLE_CHOICE_IDS, SITE_WIND_IDS, - SPACING_OPTIONS, + SPACING_CHOICE_IDS, WIND_AT_IDS, WIND_PRESET_IDS, } from '../ids'; -import type { NacelleChoiceId, SpacingD, WindAtId } from '../ids'; +import type { ChapterActionId, NacelleChoiceId, SpacingD, WindAtId } from '../ids'; import { WIND_PRESETS } from '../model'; import type { WindFarmStoreState } from '../state'; @@ -17,9 +17,7 @@ const OPEN: NacelleChoiceId = 'open'; const CLOSED: NacelleChoiceId = 'closed'; function spacingOf(value: string): SpacingD { - const spacing = SPACING_OPTIONS.find((option) => String(option) === value); - if (spacing === undefined) throw new Error(`Unexpected spacing "${value}"`); - return spacing; + return Number(parseOption(value, SPACING_CHOICE_IDS)) as SpacingD; } function overrideOf(choice: WindAtId): number | null { @@ -31,7 +29,7 @@ function windAtOf(override: number | null): WindAtId | typeof NOTHING_CURRENT { return WIND_PRESET_IDS.find((preset) => WIND_PRESETS[preset] === override) ?? NOTHING_CURRENT; } -export const CHAPTER_ACTIONS: Record> = { +export const CHAPTER_ACTIONS: Record> = { siteWind: { run: (state, value) => state.setSiteWind(parseOption(value, SITE_WIND_IDS)), current: (state) => state.siteWind, From b608631c9d6d7714a323dff14f582f46fa07c4eb Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 11:00:01 +0200 Subject: [PATCH 045/132] refactor(wind-farm): derive the chapter opening times from minutes per hour --- explainers/wind-farm/src/state/presets.test.ts | 9 ++++++++- explainers/wind-farm/src/state/presets.ts | 10 +++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/explainers/wind-farm/src/state/presets.test.ts b/explainers/wind-farm/src/state/presets.test.ts index ac0614a1..a00da8a2 100644 --- a/explainers/wind-farm/src/state/presets.test.ts +++ b/explainers/wind-farm/src/state/presets.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { PRESET_IDS, SCENE_PARTS } from '../ids'; -import { CYCLE_MINUTES, DEFAULT_SPEED, PHASE_RANGES } from '../model'; +import { CYCLE_MINUTES, DEFAULT_SPEED, PHASE_RANGES, clockOf } from '../model'; import { sceneOf } from './derived'; import { PRESETS } from './presets'; @@ -43,4 +43,11 @@ describe('wind farm presets', () => { }); expect(PRESETS.wakes.pauseAt).toBeGreaterThanOrEqual(PHASE_RANGES.morning[0]); }); + + it('opens each chapter on the hour', () => { + const opening = PRESET_IDS.map((id) => + clockOf(PRESETS[id].startAt ?? PRESETS[id].pauseAt ?? 0), + ); + expect(opening).toEqual(['12:00', '12:00', '13:00', '06:00', '09:00', '12:00']); + }); }); diff --git a/explainers/wind-farm/src/state/presets.ts b/explainers/wind-farm/src/state/presets.ts index dff732fa..e6e5ba90 100644 --- a/explainers/wind-farm/src/state/presets.ts +++ b/explainers/wind-farm/src/state/presets.ts @@ -1,6 +1,6 @@ import type { ScenePreset } from '@core/scene/presetBinder'; import type { CameraView, PartId, PresetId, SpacingD, ViewOptions } from '../ids'; -import { DEFAULT_SPACING_D, DEFAULT_SPEED } from '../model'; +import { DEFAULT_SPACING_D, DEFAULT_SPEED, MINUTES_PER_HOUR } from '../model'; export interface ChapterControls { spacing: SpacingD; @@ -12,10 +12,10 @@ export interface Preset extends ScenePreset { start?: Partial; } -const NOON = 720; -const EARLY_AFTERNOON = 780; -const DAWN = 360; -const MID_MORNING = 540; +const NOON = 12 * MINUTES_PER_HOUR; +const EARLY_AFTERNOON = 13 * MINUTES_PER_HOUR; +const DAWN = 6 * MINUTES_PER_HOUR; +const MID_MORNING = 9 * MINUTES_PER_HOUR; const FOLLOW_THE_DAY: Partial = { windOverride: null }; const DEFAULT_FARM: Partial = { spacing: DEFAULT_SPACING_D, windOverride: null }; From 9efbb6409d14efab51d75d4ac9f7ffa48a210f58 Mon Sep 17 00:00:00 2001 From: Vitalii Elenhaupt Date: Thu, 8 Oct 2026 10:54:38 +0200 Subject: [PATCH 046/132] fix(wind-farm): apply the review's English copy items Explain the daily wind swing, scope the capacity factor note to the typical and windy sites, mention the shared evening storm, describe toggles that are already on, fix the stagger, ground, full power, clock speed and project area wording, caption the power curve dot, and move the transformer and tilt into their own tower paragraph in every locale. --- explainers/wind-farm/chapters.html | 71 +++++++++++++++------------- explainers/wind-farm/locales/de.json | 3 +- explainers/wind-farm/locales/en.json | 29 ++++++------ explainers/wind-farm/locales/es.json | 3 +- explainers/wind-farm/locales/fr.json | 3 +- explainers/wind-farm/locales/ja.json | 3 +- explainers/wind-farm/locales/pt.json | 3 +- explainers/wind-farm/locales/uk.json | 3 +- explainers/wind-farm/locales/zh.json | 3 +- 9 files changed, 67 insertions(+), 54 deletions(-) diff --git a/explainers/wind-farm/chapters.html b/explainers/wind-farm/chapters.html index daf9c47c..ce325919 100644 --- a/explainers/wind-farm/chapters.html +++ b/explainers/wind-farm/chapters.html @@ -11,8 +11,8 @@

Twenty-seven turbines and on The turbines stand in rows across the prevailing wind, the direction the wind blows from most often. Here that is the west. Engineers measure the gaps in rotor widths, written D. One D is the width of a rotor from tip to tip, 150 m here. The rows are - 7 D apart, or 1,050 m. Within a row, turbines are 4 D apart, or 600 m. Each row is shifted a - little sideways, so no turbine hides right behind another. + 7 D apart, or 1,050 m. Within a row, turbines are 4 D apart, or 600 m. The middle row is shifted + sideways, so a turbine never stands right behind one in the next row.

From above it looks crowded, but most of the land is still fields. Only about 0.3 hectares per @@ -24,10 +24,10 @@

Twenty-seven turbines and on

The real layout of Målarberget is not published, so this one is the model's own. The day of wind is made up too, and it plays on a loop. It is not an average day: it packs a calm night, a - breezy morning, a full-power afternoon and an evening storm into 24 hours. The clock runs - hundreds of times faster than real time, yet every rotor turns at its real speed. Roads, cables - and wind arrows are drawn wider than they are, so you can see them from kilometres away. The - light stays the same at every hour. + breezy morning, a full-power afternoon and an evening storm into 24 hours. The clock runs many + times faster than real time, yet every rotor turns at its real speed. Roads, cables and wind + arrows are drawn wider than they are, so you can see them from kilometres away. The light stays + the same at every hour.

@@ -51,7 +51,7 @@

Twenty-seven turbines and on
34 ha

-
Whole project area
+
Typical project area for this size
39 km²
@@ -83,10 +83,12 @@

Blades that fly in circles

The circle the blades sweep is the swept area: about 17,700 m², or about 2.5 football pitches. Only wind that passes through this circle can turn the rotor. The lines of air - round the rotor are a drawing, since real air is invisible. The turbine's - transformer raises the voltage of its power for the cables. Where the V150 - keeps it is not published, so this model puts it at the foot of the tower. For the same reason, - the rotor's slight tilt is not drawn. + round the rotor are a drawing, since real air is invisible. +

+

+ The turbine's transformer raises the voltage of its power for the cables. Where + the V150 keeps it is not published, so this model puts it at the foot of the tower. For the same + reason, the rotor's slight tilt is not drawn.

@@ -242,11 +244,11 @@

How much wind a turbine ca under half, at about 7 to 8 m/s. The chart shows how close it comes.

- Above full power the wind brings more than the generator can take. So the pitch cylinders turn - the blades a little out of the wind, and most of the wind slips past. At 15 m/s the turbine - keeps only about 12 % of the power in the wind. The pitch angles this model shows above full - power are its own, since the V150's are not published. Above 20 m/s, the turbine starts to ramp - its power down. + Above about 12 m/s, the wind brings more than the generator can take. So the pitch cylinders + turn the blades a little out of the wind, and most of the wind slips past. At 15 m/s the turbine + keeps only about 12 % of the power in the wind. The pitch angles this model shows in strong wind + are its own, since the V150's are not published. Above 20 m/s, the turbine starts to ramp its + power down.

At the cut-out speed, about 22 to 25 m/s, the wind is too strong to work in. @@ -332,7 +334,8 @@

How much wind a turbine ca
- Power in the wind, the Betz limit and this turbine's output, for winds from 0 to 30 m/s + Power in the wind, the Betz limit and this turbine's output, for winds from 0 to 30 m/s. The + dot marks the wind now.
@@ -341,7 +344,7 @@

How much wind a turbine ca
5.54 MW

-
Share it catches
+
Share of the wind's power it catches
46 %
@@ -360,10 +363,10 @@

How much wind a turbine ca

Why turbines stand so far apart

A turbine takes energy out of the wind, so the air behind it moves slower. This trail of slow, - stirred-up air is called a wake, like the trail behind a boat. Switch on Wakes - (K) to see a plume behind every turning rotor. Wakes are strongest in moderate wind, when the - rotor holds back the air the most. Above full power the blades let more wind slip past, and the - wakes fade. + stirred-up air is called a wake, like the trail behind a boat. Wakes (K) shows + a plume behind every turning rotor. Switch it off to compare. Wakes are strongest in moderate + wind, when the rotor holds back the air the most. In stronger wind the blades let more air slip + past, and the wakes fade.

This model draws wakes with a simple, widely used recipe, the Jensen model. It @@ -376,13 +379,14 @@

Why turbines stand so far Over a year, wakes cost a farm about 5 to 15 % of its energy. In a Vestas example farm of 24 of these turbines, the loss is about 6 %. When the wind comes from one unlucky direction, the loss can pass 30 %. That is why rows usually stand 5 to 9 D apart along the wind, and turbines 3 to 5 - D apart across it. Try 5, 7 and 9 D: wider gaps lose less to wakes but need more land. + D apart across it. Try 5, 7 and 9 D: wider gaps lose less to wakes but need more land. In this + model the wind swings a few degrees round west through the day, so the nacelles turn a little.

- Wind also gets stronger with height, because the ground drags on the air near it. Switch on - Airflow (F) to see this: the top of the rotor meets faster wind than the bottom. The plumes and - arrows are only drawings. In this model the ground is flat, a stopped rotor leaves no wake, and - overlapping wakes add up in a simple way. + Wind also gets stronger with height, because the ground drags on the air near it. Airflow (F) + shows this: the top of the rotor meets faster wind than the bottom. The plumes and arrows are + only drawings. The wake model treats the ground as flat, leaves no wake behind a stopped rotor + and adds up overlapping wakes in a simple way.

-
Wake slowdown at the next row
+
Slowdown in the wake, one row behind
13 %
@@ -443,8 +447,8 @@

The road to the grid

Each generator makes power at 800 volts (V). After the converter, the turbine's own transformer raises the voltage. At a higher voltage the same power needs less - current, so the cables waste less of it as heat. Switch on Cables (C) to follow the power from - the turbines to the grid. + current, so the cables waste less of it as heat. Cables (C) shows the path of the power from the + turbines to the grid.

Collector cables at 36,000 volts, or 36 kilovolts (kV), link the turbines to @@ -455,15 +459,16 @@

The road to the grid

At full power, one turbine makes 4.2 megawatt hours (MWh) every hour. But the wind is not always strong. Over a year, a turbine averages about 40 % of its full power. That share is its capacity factor. In a year, one turbine makes about 14,700 MWh, enough for - about 1,400 US homes. The model's made-up day is windier than an average day, so its capacity - factor comes out higher. + about 1,400 US homes. On the typical and windy sites the model's made-up day is windier than an + average day, so its capacity factor comes out higher.

Building a turbine costs energy and carbon too. Over its whole life, it causes about 7 g of CO2e for each kilowatt hour (kWh) it makes. CO2e counts every greenhouse gas as if it were carbon dioxide. A turbine pays back the energy used to build it in about 8 months. It is designed for 20 years and often runs longer, though its blades are still hard to recycle. Pick a calm, - typical or windy site to see how much the everyday wind matters. + typical or windy site to see how much the everyday wind matters. The evening storm is the same + at every site. Only the everyday wind changes.

04

How much wind a turbine can catch

- The rotor waits until the wind reaches 3 m/s, the cut-in speed. Then power - climbs fast. The power in the wind grows with the speed cubed: speed times speed times speed. - Double the wind, and it carries eight times the power. So the turbine makes about 0.3 MW at 4 - m/s, about 2.5 MW at 8 m/s and its full 4.2 MW from about 12 m/s. Drag the wind slider and watch - the dot move along the curve. + A turbine starts making power in a light breeze of 3 m/s, the cut-in speed. + From there, the power climbs fast. The power in the wind grows with the speed cubed: speed times + speed times speed. Double the wind, and it carries eight times the power. So the turbine makes + about 0.3 MW at 4 m/s, about 2.5 MW at 8 m/s and its full 4.2 MW from about 12 m/s. Drag the + wind slider and watch the dot move along the curve.

- No turbine can catch all of that power. To take energy, the rotor must slow the air down, but - the air still has to flow away behind it. If the rotor stopped the air completely, no new air - could get through. The physicist Albert Betz showed that even a perfect rotor can take at most - 59.3 % of the wind's power. That is the Betz limit. This turbine's best is just - under half, at about 7 to 8 m/s. The chart shows how close it comes. + No turbine can catch all of that power. To take energy, the rotor must slow the air down. But + the slowed air still has to flow away behind it. If the rotor stopped the air completely, no new + air could get through. The physicist Albert Betz showed that even a perfect rotor can take at + most 59.3 % of the wind's power. That is the Betz limit. This turbine's best is + just under half the wind's power, at about 7 to 8 m/s. The chart shows how close it comes.

- Above about 12 m/s, the wind brings more than the generator can take. So the pitch cylinders - turn the blades a little out of the wind, and most of the wind slips past. At 15 m/s the turbine - keeps only about 12 % of the power in the wind. The pitch angles this model shows in strong wind - are its own, since the V150's are not published. Above 20 m/s, the turbine starts to ramp its - power down. + Above about 12 m/s, the wind brings more power than the generator can take. So the pitch + cylinders turn the blades a little out of the wind, and more of it slips past. At 15 m/s, the + turbine keeps only about 12 % of the power in the wind. Above 20 m/s, it starts to ramp down, + lowering its power step by step. The pitch angles the model shows in strong wind are its own + guess, since the V150's are not published.

- At the cut-out speed, about 22 to 25 m/s, the wind is too strong to work in. - The blades turn edge-on to the wind. This is feathering: the rotor slows down - on its own, and then the disc brake parks it. The turbine starts again only once the wind has - dropped by about 2 m/s. In this model all 27 turbines stop together at 24.5 m/s and restart at - 22.5 m/s. Stopping and restarting each take 20 minutes on the clock, longer than in real life, - so you can watch them. + At the cut-out speed, about 22 to 25 m/s, the wind is too strong and the + turbine shuts down. The blades turn edge-on to the wind. This is called + feathering. The rotor slows down on its own, and then the disc brake parks it. + The turbine starts again only once the wind has dropped by about 2 m/s. In this model, all 27 + turbines stop together at 24.5 m/s and restart at 22.5 m/s. Stopping and restarting each take 20 + minutes on the model's clock, longer than in real life, so you have time to watch.

@@ -363,30 +366,31 @@

How much wind a turbine ca

Why turbines stand so far apart

A turbine takes energy out of the wind, so the air behind it moves slower. This trail of slow, - stirred-up air is called a wake, like the trail behind a boat. Wakes (K) shows - a plume behind every turning rotor. Switch it off to compare. Wakes are strongest in moderate - wind, when the rotor holds back the air the most. In stronger wind the blades let more air slip + stirred-up air is called a wake, like the trail behind a boat. Wakes (K) draws + a plume behind every turning rotor. Switch it off to compare. Wakes are strongest in medium + winds, when the rotor holds back the most air. In stronger wind, the blades let more air slip past, and the wakes fade.

- This model draws wakes with a simple, widely used recipe, the Jensen model. It + The wakes here follow the Jensen model, a simple recipe engineers use a lot. It treats each wake as a cone that widens and slowly fills back up with faster air. One number sets - how fast it widens: 0.075 here, the usual value on land. At 8 m/s, a turbine 7 D downwind still - meets air about 13 % slower. Power follows the speed cubed, so that costs it about a third of - its power. + how fast the cone widens: 0.075 here, the usual value on land. At 8 m/s, a turbine 7 D downwind + still meets air about 13 % slower. Since power follows the speed cubed, that costs it about a + third of its power.

Over a year, wakes cost a farm about 5 to 15 % of its energy. In a Vestas example farm of 24 of - these turbines, the loss is about 6 %. When the wind comes from one unlucky direction, the loss - can pass 30 %. That is why rows usually stand 5 to 9 D apart along the wind, and turbines 3 to 5 - D apart across it. Try 5, 7 and 9 D: wider gaps lose less to wakes but need more land. In this - model the wind swings a few degrees round west through the day, so the nacelles turn a little. + these turbines, the loss is about 6 %. From one unlucky direction, where turbines line up one + behind another, the loss can pass 30 %. That is why rows usually stand 5 to 9 D apart along the + wind, and turbines 3 to 5 D apart across it. Try 5, 7 and 9 D: wider gaps lose less to wakes but + need more land. In this model, the wind also swings a few degrees either side of west through + the day, so the nacelles turn a little.

- Wind also gets stronger with height, because the ground drags on the air near it. Airflow (F) + Wind also gets stronger with height, because the ground slows down the air near it. Airflow (F) shows this: the top of the rotor meets faster wind than the bottom. The plumes and arrows are - only drawings. The wake model treats the ground as flat, leaves no wake behind a stopped rotor - and adds up overlapping wakes in a simple way. + only drawings. The wake model also cuts corners. It treats the ground as flat, leaves no wake + behind a stopped rotor and only roughly adds up wakes that overlap.