@jjlmoya/utils-drones 1.35.0 → 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/category/index.ts +4 -0
- package/src/entries.ts +8 -0
- package/src/index.ts +1 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/drone-mission-battery-reserve-planner/bibliography.astro +6 -0
- package/src/tool/drone-mission-battery-reserve-planner/bibliography.ts +7 -0
- package/src/tool/drone-mission-battery-reserve-planner/component.astro +229 -0
- package/src/tool/drone-mission-battery-reserve-planner/controller.ts +124 -0
- package/src/tool/drone-mission-battery-reserve-planner/dom-views.ts +114 -0
- package/src/tool/drone-mission-battery-reserve-planner/drone-mission-battery-reserve-planner.css +392 -0
- package/src/tool/drone-mission-battery-reserve-planner/entry.ts +27 -0
- package/src/tool/drone-mission-battery-reserve-planner/evaluator.ts +23 -0
- package/src/tool/drone-mission-battery-reserve-planner/i18n/de.ts +223 -0
- package/src/tool/drone-mission-battery-reserve-planner/i18n/en.ts +223 -0
- package/src/tool/drone-mission-battery-reserve-planner/i18n/es.ts +223 -0
- package/src/tool/drone-mission-battery-reserve-planner/i18n/fr.ts +223 -0
- package/src/tool/drone-mission-battery-reserve-planner/i18n/id.ts +223 -0
- package/src/tool/drone-mission-battery-reserve-planner/i18n/it.ts +223 -0
- package/src/tool/drone-mission-battery-reserve-planner/i18n/ja.ts +223 -0
- package/src/tool/drone-mission-battery-reserve-planner/i18n/ko.ts +223 -0
- package/src/tool/drone-mission-battery-reserve-planner/i18n/nl.ts +223 -0
- package/src/tool/drone-mission-battery-reserve-planner/i18n/pl.ts +223 -0
- package/src/tool/drone-mission-battery-reserve-planner/i18n/pt.ts +223 -0
- package/src/tool/drone-mission-battery-reserve-planner/i18n/ru.ts +223 -0
- package/src/tool/drone-mission-battery-reserve-planner/i18n/sv.ts +223 -0
- package/src/tool/drone-mission-battery-reserve-planner/i18n/tr.ts +223 -0
- package/src/tool/drone-mission-battery-reserve-planner/i18n/zh.ts +223 -0
- package/src/tool/drone-mission-battery-reserve-planner/index.ts +11 -0
- package/src/tool/drone-mission-battery-reserve-planner/logic.test.ts +94 -0
- package/src/tool/drone-mission-battery-reserve-planner/logic.ts +203 -0
- package/src/tool/drone-mission-battery-reserve-planner/seo.astro +15 -0
- package/src/tool/drone-mission-battery-reserve-planner/storage.ts +59 -0
- package/src/tool/drone-mission-battery-reserve-planner/ui.ts +78 -0
- package/src/tool/fpv-drone-speed-calculator/bibliography.astro +6 -0
- package/src/tool/fpv-drone-speed-calculator/bibliography.ts +14 -0
- package/src/tool/fpv-drone-speed-calculator/component.astro +115 -0
- package/src/tool/fpv-drone-speed-calculator/controller.ts +105 -0
- package/src/tool/fpv-drone-speed-calculator/dom-views.ts +80 -0
- package/src/tool/fpv-drone-speed-calculator/entry.ts +27 -0
- package/src/tool/fpv-drone-speed-calculator/fpv-drone-speed-calculator.css +558 -0
- package/src/tool/fpv-drone-speed-calculator/i18n/de.ts +20 -0
- package/src/tool/fpv-drone-speed-calculator/i18n/en.ts +47 -0
- package/src/tool/fpv-drone-speed-calculator/i18n/es.ts +40 -0
- package/src/tool/fpv-drone-speed-calculator/i18n/fr.ts +13 -0
- package/src/tool/fpv-drone-speed-calculator/i18n/id.ts +13 -0
- package/src/tool/fpv-drone-speed-calculator/i18n/it.ts +13 -0
- package/src/tool/fpv-drone-speed-calculator/i18n/ja.ts +13 -0
- package/src/tool/fpv-drone-speed-calculator/i18n/ko.ts +13 -0
- package/src/tool/fpv-drone-speed-calculator/i18n/nl.ts +13 -0
- package/src/tool/fpv-drone-speed-calculator/i18n/pl.ts +13 -0
- package/src/tool/fpv-drone-speed-calculator/i18n/pt.ts +13 -0
- package/src/tool/fpv-drone-speed-calculator/i18n/ru.ts +13 -0
- package/src/tool/fpv-drone-speed-calculator/i18n/sv.ts +13 -0
- package/src/tool/fpv-drone-speed-calculator/i18n/tr.ts +13 -0
- package/src/tool/fpv-drone-speed-calculator/i18n/zh.ts +13 -0
- package/src/tool/fpv-drone-speed-calculator/index.ts +11 -0
- package/src/tool/fpv-drone-speed-calculator/logic.test.ts +53 -0
- package/src/tool/fpv-drone-speed-calculator/logic.ts +126 -0
- package/src/tool/fpv-drone-speed-calculator/seo.astro +16 -0
- package/src/tool/fpv-drone-speed-calculator/ui.ts +47 -0
- package/src/tools.ts +7 -0
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
export type UnitSystem = 'metric' | 'imperial';
|
|
2
|
+
export type WindDirection = 'headwind' | 'tailwind' | 'crosswind';
|
|
3
|
+
export type MissionStatusKey = 'optimal' | 'tight' | 'critical' | 'exceeded';
|
|
4
|
+
|
|
5
|
+
export interface MissionInputs {
|
|
6
|
+
unitSystem: UnitSystem;
|
|
7
|
+
batteryCapacityMah: number;
|
|
8
|
+
voltageNominal: number;
|
|
9
|
+
averageCurrentAmps: number;
|
|
10
|
+
cruiseSpeedKmh: number;
|
|
11
|
+
oneWayDistanceKm: number;
|
|
12
|
+
targetHoverTimeMin: number;
|
|
13
|
+
windSpeedKmh: number;
|
|
14
|
+
windDirection: WindDirection;
|
|
15
|
+
reservePolicyPercent: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface MissionResults {
|
|
19
|
+
totalEnergyWh: number;
|
|
20
|
+
usableEnergyWh: number;
|
|
21
|
+
reserveEnergyWh: number;
|
|
22
|
+
basePowerDrawWatts: number;
|
|
23
|
+
outboundPowerWatts: number;
|
|
24
|
+
returnPowerWatts: number;
|
|
25
|
+
totalAutonomyMinutes: number;
|
|
26
|
+
outboundSpeedKmh: number;
|
|
27
|
+
returnSpeedKmh: number;
|
|
28
|
+
outboundTimeMinutes: number;
|
|
29
|
+
targetHoverTimeMinutes: number;
|
|
30
|
+
returnTimeMinutes: number;
|
|
31
|
+
totalMissionTimeMinutes: number;
|
|
32
|
+
outboundEnergyWh: number;
|
|
33
|
+
targetHoverEnergyWh: number;
|
|
34
|
+
returnEnergyWh: number;
|
|
35
|
+
missionEnergyUsedWh: number;
|
|
36
|
+
remainingEnergyLandingWh: number;
|
|
37
|
+
remainingEnergyLandingPercent: number;
|
|
38
|
+
maxSafeRadiusKm: number;
|
|
39
|
+
voltageSagWhLoss: number;
|
|
40
|
+
statusKey: MissionStatusKey;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const KMH_TO_MPH = 0.621371;
|
|
44
|
+
const KM_TO_MILES = 0.621371;
|
|
45
|
+
|
|
46
|
+
export function clampValue(val: number, min: number, max: number): number {
|
|
47
|
+
return Math.min(Math.max(val, min), max);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function computeEffectiveSpeeds(
|
|
51
|
+
cruiseKmh: number,
|
|
52
|
+
windKmh: number,
|
|
53
|
+
dir: WindDirection
|
|
54
|
+
): { outbound: number; returnLeg: number } {
|
|
55
|
+
if (dir === 'headwind') {
|
|
56
|
+
return { outbound: Math.max(1, cruiseKmh - windKmh), returnLeg: cruiseKmh + windKmh * 0.85 };
|
|
57
|
+
}
|
|
58
|
+
if (dir === 'tailwind') {
|
|
59
|
+
return { outbound: cruiseKmh + windKmh * 0.85, returnLeg: Math.max(1, cruiseKmh - windKmh) };
|
|
60
|
+
}
|
|
61
|
+
const effective = Math.max(1, Math.sqrt(Math.max(0, cruiseKmh * cruiseKmh - windKmh * windKmh * 0.5)));
|
|
62
|
+
return { outbound: effective, returnLeg: effective };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function calculateWindPowerPenalty(
|
|
66
|
+
basePowerWatts: number,
|
|
67
|
+
cruiseKmh: number,
|
|
68
|
+
windKmh: number,
|
|
69
|
+
dir: WindDirection
|
|
70
|
+
): { outboundPower: number; returnPower: number } {
|
|
71
|
+
const windRatio = windKmh / Math.max(10, cruiseKmh);
|
|
72
|
+
|
|
73
|
+
if (dir === 'headwind') {
|
|
74
|
+
return {
|
|
75
|
+
outboundPower: basePowerWatts * Math.pow(1 + 0.65 * windRatio, 1.3),
|
|
76
|
+
returnPower: basePowerWatts * Math.max(0.75, 1 - 0.35 * windRatio),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (dir === 'tailwind') {
|
|
80
|
+
return {
|
|
81
|
+
outboundPower: basePowerWatts * Math.max(0.75, 1 - 0.35 * windRatio),
|
|
82
|
+
returnPower: basePowerWatts * Math.pow(1 + 0.65 * windRatio, 1.3),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
const crossPower = basePowerWatts * (1 + 0.25 * windRatio);
|
|
86
|
+
return { outboundPower: crossPower, returnPower: crossPower };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function calculateEnergyMetrics(inputs: MissionInputs): {
|
|
90
|
+
totalEnergyWh: number;
|
|
91
|
+
usableEnergyWh: number;
|
|
92
|
+
reserveEnergyWh: number;
|
|
93
|
+
basePowerDrawWatts: number;
|
|
94
|
+
totalAutonomyMinutes: number;
|
|
95
|
+
voltageSagWhLoss: number;
|
|
96
|
+
} {
|
|
97
|
+
const cap = Math.max(1, inputs.batteryCapacityMah);
|
|
98
|
+
const volt = Math.max(0.1, inputs.voltageNominal);
|
|
99
|
+
const current = Math.max(0.1, inputs.averageCurrentAmps);
|
|
100
|
+
const reservePct = clampValue(inputs.reservePolicyPercent, 5, 50) / 100;
|
|
101
|
+
|
|
102
|
+
const grossWh = (cap * volt) / 1000;
|
|
103
|
+
const sagLoss = grossWh * Math.min(0.12, 0.02 + 0.015 * (current / (cap / 1000)));
|
|
104
|
+
const totalWh = Math.max(0.1, grossWh - sagLoss);
|
|
105
|
+
const reserveWh = totalWh * reservePct;
|
|
106
|
+
const usableWh = totalWh - reserveWh;
|
|
107
|
+
const baseWatts = volt * current;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
totalEnergyWh: totalWh,
|
|
111
|
+
usableEnergyWh: usableWh,
|
|
112
|
+
reserveEnergyWh: reserveWh,
|
|
113
|
+
basePowerDrawWatts: baseWatts,
|
|
114
|
+
totalAutonomyMinutes: (usableWh / baseWatts) * 60,
|
|
115
|
+
voltageSagWhLoss: sagLoss,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function determineMissionStatus(
|
|
120
|
+
remainingWh: number,
|
|
121
|
+
reserveWh: number,
|
|
122
|
+
_totalEnergyWh: number
|
|
123
|
+
): MissionStatusKey {
|
|
124
|
+
if (remainingWh < 0) return 'exceeded';
|
|
125
|
+
if (remainingWh < reserveWh * 0.5) return 'critical';
|
|
126
|
+
if (remainingWh < reserveWh) return 'tight';
|
|
127
|
+
return 'optimal';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface LegParams {
|
|
131
|
+
distKm: number;
|
|
132
|
+
hoverMin: number;
|
|
133
|
+
baseWatts: number;
|
|
134
|
+
outboundSpeed: number;
|
|
135
|
+
returnSpeed: number;
|
|
136
|
+
outboundPower: number;
|
|
137
|
+
returnPower: number;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function calculateTransitLegs(p: LegParams) {
|
|
141
|
+
const outboundTimeMin = (p.distKm / p.outboundSpeed) * 60;
|
|
142
|
+
const returnTimeMin = (p.distKm / p.returnSpeed) * 60;
|
|
143
|
+
const outboundEnergyWh = (outboundTimeMin / 60) * p.outboundPower;
|
|
144
|
+
const hoverEnergyWh = (p.hoverMin / 60) * p.baseWatts;
|
|
145
|
+
const returnEnergyWh = (returnTimeMin / 60) * p.returnPower;
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
outboundTimeMin, returnTimeMin, hoverTimeMin: p.hoverMin,
|
|
149
|
+
totalMissionTimeMin: outboundTimeMin + p.hoverMin + returnTimeMin,
|
|
150
|
+
outboundEnergyWh, hoverEnergyWh, returnEnergyWh,
|
|
151
|
+
totalUsedWh: outboundEnergyWh + hoverEnergyWh + returnEnergyWh,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface SafeRadiusParams {
|
|
156
|
+
usableWh: number;
|
|
157
|
+
hoverWh: number;
|
|
158
|
+
outboundPower: number;
|
|
159
|
+
returnPower: number;
|
|
160
|
+
outboundSpeed: number;
|
|
161
|
+
returnSpeed: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function calculateSafeRadius(p: SafeRadiusParams): number {
|
|
165
|
+
const transitWh = Math.max(0, p.usableWh - p.hoverWh);
|
|
166
|
+
const perKm = (p.outboundPower / p.outboundSpeed) + (p.returnPower / p.returnSpeed);
|
|
167
|
+
return perKm > 0 ? Math.max(0, transitWh / perKm) : 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function calculateMissionReserve(inputs: MissionInputs): MissionResults {
|
|
171
|
+
const e = calculateEnergyMetrics(inputs);
|
|
172
|
+
const s = computeEffectiveSpeeds(Math.max(1, inputs.cruiseSpeedKmh), Math.max(0, inputs.windSpeedKmh), inputs.windDirection);
|
|
173
|
+
const p = calculateWindPowerPenalty(e.basePowerDrawWatts, Math.max(1, inputs.cruiseSpeedKmh), Math.max(0, inputs.windSpeedKmh), inputs.windDirection);
|
|
174
|
+
const legs = calculateTransitLegs({
|
|
175
|
+
distKm: Math.max(0, inputs.oneWayDistanceKm), hoverMin: Math.max(0, inputs.targetHoverTimeMin),
|
|
176
|
+
baseWatts: e.basePowerDrawWatts, outboundSpeed: s.outbound, returnSpeed: s.returnLeg,
|
|
177
|
+
outboundPower: p.outboundPower, returnPower: p.returnPower,
|
|
178
|
+
});
|
|
179
|
+
const maxSafeRadiusKm = calculateSafeRadius({
|
|
180
|
+
usableWh: e.usableEnergyWh, hoverWh: legs.hoverEnergyWh, outboundPower: p.outboundPower,
|
|
181
|
+
returnPower: p.returnPower, outboundSpeed: s.outbound, returnSpeed: s.returnLeg,
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const remWh = e.totalEnergyWh - legs.totalUsedWh;
|
|
185
|
+
return {
|
|
186
|
+
totalEnergyWh: e.totalEnergyWh, usableEnergyWh: e.usableEnergyWh, reserveEnergyWh: e.reserveEnergyWh,
|
|
187
|
+
basePowerDrawWatts: e.basePowerDrawWatts, outboundPowerWatts: p.outboundPower, returnPowerWatts: p.returnPower,
|
|
188
|
+
totalAutonomyMinutes: e.totalAutonomyMinutes, outboundSpeedKmh: s.outbound, returnSpeedKmh: s.returnLeg,
|
|
189
|
+
outboundTimeMinutes: legs.outboundTimeMin, targetHoverTimeMinutes: legs.hoverTimeMin, returnTimeMinutes: legs.returnTimeMin,
|
|
190
|
+
totalMissionTimeMinutes: legs.totalMissionTimeMin, outboundEnergyWh: legs.outboundEnergyWh, targetHoverEnergyWh: legs.hoverEnergyWh,
|
|
191
|
+
returnEnergyWh: legs.returnEnergyWh, missionEnergyUsedWh: legs.totalUsedWh, remainingEnergyLandingWh: remWh,
|
|
192
|
+
remainingEnergyLandingPercent: (remWh / e.totalEnergyWh) * 100, maxSafeRadiusKm, voltageSagWhLoss: e.voltageSagWhLoss,
|
|
193
|
+
statusKey: determineMissionStatus(remWh, e.reserveEnergyWh, e.totalEnergyWh),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function convertDistance(km: number, unit: UnitSystem): number {
|
|
198
|
+
return unit === 'imperial' ? km * KM_TO_MILES : km;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function convertSpeed(kmh: number, unit: UnitSystem): number {
|
|
202
|
+
return unit === 'imperial' ? kmh * KMH_TO_MPH : kmh;
|
|
203
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
---
|
|
2
|
+
import { SEORenderer } from '@jjlmoya/utils-shared';
|
|
3
|
+
import { droneMissionBatteryReservePlanner } from './entry';
|
|
4
|
+
import type { KnownLocale } from '../../types';
|
|
5
|
+
|
|
6
|
+
interface Props {
|
|
7
|
+
locale?: KnownLocale;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const { locale = 'en' } = Astro.props;
|
|
11
|
+
const content = await droneMissionBatteryReservePlanner.i18n[locale]?.();
|
|
12
|
+
if (!content) return null;
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
{content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { MissionInputs, UnitSystem, WindDirection } from './logic';
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = 'jjlmoya_drone_mission_battery_reserve_planner_state';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_MISSION_INPUTS: MissionInputs = {
|
|
6
|
+
unitSystem: 'metric',
|
|
7
|
+
batteryCapacityMah: 10000,
|
|
8
|
+
voltageNominal: 22.2,
|
|
9
|
+
averageCurrentAmps: 18,
|
|
10
|
+
cruiseSpeedKmh: 45,
|
|
11
|
+
oneWayDistanceKm: 6,
|
|
12
|
+
targetHoverTimeMin: 5,
|
|
13
|
+
windSpeedKmh: 12,
|
|
14
|
+
windDirection: 'headwind',
|
|
15
|
+
reservePolicyPercent: 20,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function parseNumberField(val: unknown, fallback: number): number {
|
|
19
|
+
return typeof val === 'number' && !Number.isNaN(val) ? val : fallback;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function parseUnitSystem(val: unknown): UnitSystem {
|
|
23
|
+
return val === 'imperial' ? 'imperial' : 'metric';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseWindDirection(val: unknown): WindDirection {
|
|
27
|
+
if (val === 'tailwind' || val === 'crosswind') return val;
|
|
28
|
+
return 'headwind';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function loadSavedInputs(): MissionInputs {
|
|
32
|
+
try {
|
|
33
|
+
const raw = localStorage.getItem(STORAGE_KEY);
|
|
34
|
+
if (!raw) return DEFAULT_MISSION_INPUTS;
|
|
35
|
+
const parsed = JSON.parse(raw);
|
|
36
|
+
if (!parsed || typeof parsed !== 'object') return DEFAULT_MISSION_INPUTS;
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
unitSystem: parseUnitSystem(parsed.unitSystem),
|
|
40
|
+
batteryCapacityMah: parseNumberField(parsed.batteryCapacityMah, DEFAULT_MISSION_INPUTS.batteryCapacityMah),
|
|
41
|
+
voltageNominal: parseNumberField(parsed.voltageNominal, DEFAULT_MISSION_INPUTS.voltageNominal),
|
|
42
|
+
averageCurrentAmps: parseNumberField(parsed.averageCurrentAmps, DEFAULT_MISSION_INPUTS.averageCurrentAmps),
|
|
43
|
+
cruiseSpeedKmh: parseNumberField(parsed.cruiseSpeedKmh, DEFAULT_MISSION_INPUTS.cruiseSpeedKmh),
|
|
44
|
+
oneWayDistanceKm: parseNumberField(parsed.oneWayDistanceKm, DEFAULT_MISSION_INPUTS.oneWayDistanceKm),
|
|
45
|
+
targetHoverTimeMin: parseNumberField(parsed.targetHoverTimeMin, DEFAULT_MISSION_INPUTS.targetHoverTimeMin),
|
|
46
|
+
windSpeedKmh: parseNumberField(parsed.windSpeedKmh, DEFAULT_MISSION_INPUTS.windSpeedKmh),
|
|
47
|
+
windDirection: parseWindDirection(parsed.windDirection),
|
|
48
|
+
reservePolicyPercent: parseNumberField(parsed.reservePolicyPercent, DEFAULT_MISSION_INPUTS.reservePolicyPercent),
|
|
49
|
+
};
|
|
50
|
+
} catch {
|
|
51
|
+
return DEFAULT_MISSION_INPUTS;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function saveInputs(inputs: MissionInputs): void {
|
|
56
|
+
try {
|
|
57
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(inputs));
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export interface DroneMissionBatteryReservePlannerUI {
|
|
2
|
+
[key: string]: unknown;
|
|
3
|
+
title: string;
|
|
4
|
+
subtitle: string;
|
|
5
|
+
description: string;
|
|
6
|
+
sections: {
|
|
7
|
+
batteryPropulsion: string;
|
|
8
|
+
flightAtmosphere: string;
|
|
9
|
+
};
|
|
10
|
+
inputs: {
|
|
11
|
+
unitSystemLabel: string;
|
|
12
|
+
metricLabel: string;
|
|
13
|
+
imperialLabel: string;
|
|
14
|
+
presetLabel: string;
|
|
15
|
+
batteryCapacityLabel: string;
|
|
16
|
+
batteryVoltageLabel: string;
|
|
17
|
+
averageCurrentLabel: string;
|
|
18
|
+
cruiseSpeedLabel: string;
|
|
19
|
+
oneWayDistanceLabel: string;
|
|
20
|
+
targetHoverTimeLabel: string;
|
|
21
|
+
windSpeedLabel: string;
|
|
22
|
+
windDirectionLabel: string;
|
|
23
|
+
windHeadwindLabel: string;
|
|
24
|
+
windTailwindLabel: string;
|
|
25
|
+
windCrosswindLabel: string;
|
|
26
|
+
reservePolicyLabel: string;
|
|
27
|
+
};
|
|
28
|
+
presets: {
|
|
29
|
+
mappingSurvey: string;
|
|
30
|
+
fpvRecon: string;
|
|
31
|
+
cinematicInspection: string;
|
|
32
|
+
microRecon: string;
|
|
33
|
+
surveyMeta: string;
|
|
34
|
+
scoutMeta: string;
|
|
35
|
+
hoverMeta: string;
|
|
36
|
+
};
|
|
37
|
+
results: {
|
|
38
|
+
totalCapacityEnergy: string;
|
|
39
|
+
usableEnergy: string;
|
|
40
|
+
reserveEnergyBuffer: string;
|
|
41
|
+
totalAutonomyTime: string;
|
|
42
|
+
maxSafeMissionRadius: string;
|
|
43
|
+
outboundLegTime: string;
|
|
44
|
+
targetHoverTime: string;
|
|
45
|
+
returnLegTime: string;
|
|
46
|
+
totalMissionTime: string;
|
|
47
|
+
remainingEnergyLanding: string;
|
|
48
|
+
feasibilityStatus: string;
|
|
49
|
+
voltageSagSubLabel: string;
|
|
50
|
+
maxRadiusSubLabel: string;
|
|
51
|
+
powerSubLabel: string;
|
|
52
|
+
};
|
|
53
|
+
statusBadges: {
|
|
54
|
+
optimalTitle: string;
|
|
55
|
+
optimalSubtitle: string;
|
|
56
|
+
tightTitle: string;
|
|
57
|
+
tightSubtitle: string;
|
|
58
|
+
criticalTitle: string;
|
|
59
|
+
criticalSubtitle: string;
|
|
60
|
+
exceededTitle: string;
|
|
61
|
+
exceededSubtitle: string;
|
|
62
|
+
};
|
|
63
|
+
chart: {
|
|
64
|
+
batteryProfileTitle: string;
|
|
65
|
+
modelTitle: string;
|
|
66
|
+
windLabel: string;
|
|
67
|
+
homeNode: string;
|
|
68
|
+
targetNode: string;
|
|
69
|
+
landNode: string;
|
|
70
|
+
launchPadLabel: string;
|
|
71
|
+
surveyHoverLabel: string;
|
|
72
|
+
safeRadiusLabel: string;
|
|
73
|
+
outboundSegment: string;
|
|
74
|
+
targetSegment: string;
|
|
75
|
+
returnSegment: string;
|
|
76
|
+
reserveSegment: string;
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { BibliographyEntry } from '../../types';
|
|
2
|
+
|
|
3
|
+
export const bibliography: BibliographyEntry[] = [
|
|
4
|
+
{
|
|
5
|
+
name: 'Universidad Politécnica de Madrid - Comparación de una hélice de paso fijo con una de paso variable para una aplicación de un avión VTOL',
|
|
6
|
+
url: 'https://oa.upm.es/64973/',
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
name: 'NASA Technical Reports Server - Efficient Variable-Pitch Propeller Aerodynamic Model Development for Vectored-Thrust eVTOL Aircraft',
|
|
10
|
+
url: 'https://ntrs.nasa.gov/citations/20220005627',
|
|
11
|
+
},
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export const BIBLIOGRAPHY_ITEMS = bibliography;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { FpvDroneSpeedUI } from './ui';
|
|
3
|
+
|
|
4
|
+
interface Props {
|
|
5
|
+
ui: FpvDroneSpeedUI;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const { ui } = Astro.props;
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
<div class="fpv-speed-tool" data-fpv-speed-calculator data-unit="metric">
|
|
12
|
+
<script is:inline type="application/json" data-fpv-speed-ui set:html={JSON.stringify(ui)}></script>
|
|
13
|
+
|
|
14
|
+
<div class="speed-brief">
|
|
15
|
+
<div>
|
|
16
|
+
<span class="brief-kicker">{ui.calculateFromLabel}</span>
|
|
17
|
+
<p>{ui.assumptionsText}</p>
|
|
18
|
+
</div>
|
|
19
|
+
<div class="unit-switch" role="group" aria-label={ui.unitsLabel}>
|
|
20
|
+
<button type="button" class="unit-button is-selected" data-unit="metric" aria-pressed="true">{ui.metricUnit}</button>
|
|
21
|
+
<button type="button" class="unit-button" data-unit="imperial" aria-pressed="false">{ui.imperialUnit}</button>
|
|
22
|
+
</div>
|
|
23
|
+
</div>
|
|
24
|
+
|
|
25
|
+
<section class="flight-deck" aria-labelledby="speed-inputs-title">
|
|
26
|
+
<div class="control-rail">
|
|
27
|
+
<div class="rail-heading">
|
|
28
|
+
<span class="brief-kicker">{ui.inputsLabel}</span>
|
|
29
|
+
<h2 id="speed-inputs-title">{ui.presetsLabel}</h2>
|
|
30
|
+
</div>
|
|
31
|
+
<div class="preset-track">
|
|
32
|
+
<button type="button" class="preset-button" data-preset="racing">{ui.presetRacing}</button>
|
|
33
|
+
<button type="button" class="preset-button is-selected" data-preset="freestyle">{ui.presetFreestyle}</button>
|
|
34
|
+
<button type="button" class="preset-button" data-preset="cruiser">{ui.presetCruiser}</button>
|
|
35
|
+
</div>
|
|
36
|
+
<div class="input-list">
|
|
37
|
+
<label class="input-row"><span>{ui.motorKvLabel}</span><input data-field="motorKv" type="number" min="100" max="10000" step="50" inputmode="decimal" /><small>KV</small></label>
|
|
38
|
+
<label class="input-row"><span>{ui.batteryVoltageLabel}</span><input data-field="batteryVoltage" type="number" min="1" max="80" step="0.1" inputmode="decimal" /><small>V</small></label>
|
|
39
|
+
<label class="input-row"><span>{ui.propellerPitchLabel}</span><input data-field="propellerPitchInches" type="number" min="0.5" max="25" step="0.1" inputmode="decimal" /><small>in</small></label>
|
|
40
|
+
<label class="input-row"><span>{ui.efficiencyLabel}</span><input data-field="efficiencyPercent" type="number" min="50" max="98" step="1" inputmode="decimal" /><small>%</small></label>
|
|
41
|
+
<label class="input-row"><span>{ui.aircraftMassLabel}</span><input data-field="aircraftMassGrams" type="number" min="1" max="50000" step="10" inputmode="decimal" /><small data-unit-label="mass">g</small></label>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
|
|
45
|
+
<div class="speed-orchestra">
|
|
46
|
+
<div class="speed-orchestra-header">
|
|
47
|
+
<div>
|
|
48
|
+
<span class="brief-kicker">{ui.resultsLabel}</span>
|
|
49
|
+
<h2>{ui.speedLaneLabel}</h2>
|
|
50
|
+
</div>
|
|
51
|
+
<div class="diagnosis" data-output="diagnosis" aria-live="polite">{ui.diagnosisPlanning}</div>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<div class="speed-lane" aria-label={ui.speedLaneLabel}>
|
|
55
|
+
<div class="lane-grid" aria-hidden="true"></div>
|
|
56
|
+
<div class="lane-horizon" aria-hidden="true"></div>
|
|
57
|
+
<div class="lane-header">
|
|
58
|
+
<span>{ui.speedLaneLabel}</span>
|
|
59
|
+
<span><b data-output="speed-axis-start">{ui.speedAxisStart}</b><i></i><b data-output="speed-axis-end">{ui.speedAxisEnd}</b></span>
|
|
60
|
+
</div>
|
|
61
|
+
<div class="speed-comparison">
|
|
62
|
+
<div class="comparison-scale" aria-hidden="true">
|
|
63
|
+
<span data-output="speed-axis-start">{ui.speedAxisStart}</span>
|
|
64
|
+
<span data-output="speed-axis-mid">150 km/h</span>
|
|
65
|
+
<span data-output="speed-axis-end">{ui.speedAxisEnd}</span>
|
|
66
|
+
</div>
|
|
67
|
+
<div class="comparison-row">
|
|
68
|
+
<div class="comparison-label"><span>{ui.estimatedSpeedLabel}</span><strong data-output="estimated-speed">178 km/h</strong></div>
|
|
69
|
+
<div class="comparison-track"><i class="comparison-fill comparison-fill-estimated"></i></div>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="comparison-row">
|
|
72
|
+
<div class="comparison-label"><span>{ui.pitchSpeedLabel}</span><strong data-output="pitch-speed">217 km/h</strong></div>
|
|
73
|
+
<div class="comparison-track"><i class="comparison-fill comparison-fill-pitch"></i></div>
|
|
74
|
+
</div>
|
|
75
|
+
<div class="comparison-gap"><span>{ui.slipLabel}</span><strong data-output="speed-gap">39 km/h</strong><small data-output="slip">18%</small></div>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div class="speed-readouts">
|
|
80
|
+
<div class="hero-readout"><strong data-output="estimated-speed">178 km/h</strong><span>{ui.estimatedSpeedLabel}</span></div>
|
|
81
|
+
<div class="readout"><strong data-output="pitch-speed">217 km/h</strong><span>{ui.pitchSpeedLabel}</span></div>
|
|
82
|
+
<div class="readout"><strong data-output="loaded-rpm">36,000 rpm</strong><span>{ui.loadedRpmLabel}</span></div>
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<p class="diagnosis-advice" data-output="diagnosis-advice" aria-live="polite">{ui.diagnosisPlanningAdvice}</p>
|
|
86
|
+
|
|
87
|
+
<section class="sensitivity" aria-labelledby="sensitivity-title">
|
|
88
|
+
<div class="subheading"><span class="brief-kicker">{ui.sensitivityLabel}</span><h3 id="sensitivity-title">{ui.propellerPitchLabel}</h3></div>
|
|
89
|
+
<div class="sensitivity-row">
|
|
90
|
+
<div class="sensitivity-point"><span>{ui.lowerPitchLabel}</span><strong data-output="lower-speed">158 km/h</strong><small data-output="lower-pitch">3.8 in</small><div class="sensitivity-bar"><i data-bar="lower"></i></div></div>
|
|
91
|
+
<div class="sensitivity-point is-current"><span>{ui.selectedPitchLabel}</span><strong data-output="current-speed">178 km/h</strong><small data-output="current-pitch">4.3 in</small><div class="sensitivity-bar"><i data-bar="current"></i></div></div>
|
|
92
|
+
<div class="sensitivity-point"><span>{ui.higherPitchLabel}</span><strong data-output="higher-speed">199 km/h</strong><small data-output="higher-pitch">4.8 in</small><div class="sensitivity-bar"><i data-bar="higher"></i></div></div>
|
|
93
|
+
</div>
|
|
94
|
+
</section>
|
|
95
|
+
|
|
96
|
+
<div class="metric-strip">
|
|
97
|
+
<div><span>{ui.noLoadRpmLabel}</span><strong data-output="no-load-rpm">43,290 rpm</strong></div>
|
|
98
|
+
<div><span>{ui.slipLabel}</span><strong data-output="slip">18%</strong></div>
|
|
99
|
+
<div><span>{ui.loadEffectLabel}</span><strong data-output="load-effect">7%</strong></div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
</section>
|
|
103
|
+
|
|
104
|
+
<div class="assumptions-row">
|
|
105
|
+
<p><strong>{ui.assumptionsLabel}</strong> {ui.assumptionsText}</p>
|
|
106
|
+
<p>{ui.safetyText}</p>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<script>
|
|
111
|
+
import { initFpvDroneSpeedCalculator } from './controller';
|
|
112
|
+
|
|
113
|
+
const root = document.querySelector<HTMLElement>('[data-fpv-speed-calculator]');
|
|
114
|
+
if (root) initFpvDroneSpeedCalculator(root);
|
|
115
|
+
</script>
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { calculateFpvDroneSpeed, type FpvDroneSpeedInputs } from './logic';
|
|
2
|
+
import { formatMassInput, parseMassInput, renderResults, updateUnitLabels, type DisplayUnit } from './dom-views';
|
|
3
|
+
import type { FpvDroneSpeedUI } from './ui';
|
|
4
|
+
|
|
5
|
+
interface Preset {
|
|
6
|
+
id: string;
|
|
7
|
+
inputs: FpvDroneSpeedInputs;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const PRESETS: Preset[] = [
|
|
11
|
+
{ id: 'racing', inputs: { motorKv: 2450, batteryVoltage: 16.8, propellerPitchInches: 4.3, efficiencyPercent: 84, aircraftMassGrams: 620 } },
|
|
12
|
+
{ id: 'freestyle', inputs: { motorKv: 1950, batteryVoltage: 22.2, propellerPitchInches: 4.3, efficiencyPercent: 82, aircraftMassGrams: 700 } },
|
|
13
|
+
{ id: 'cruiser', inputs: { motorKv: 1500, batteryVoltage: 22.2, propellerPitchInches: 3.5, efficiencyPercent: 78, aircraftMassGrams: 1200 } },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const DEFAULT_INPUTS = PRESETS[1]!.inputs;
|
|
17
|
+
|
|
18
|
+
function input(root: HTMLElement, field: string): HTMLInputElement | null {
|
|
19
|
+
return root.querySelector<HTMLInputElement>(`[data-field="${field}"]`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function setInput(root: HTMLElement, field: string, value: string): void {
|
|
23
|
+
const element = input(root, field);
|
|
24
|
+
if (element) element.value = value;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readInputs(root: HTMLElement, unit: DisplayUnit): FpvDroneSpeedInputs {
|
|
28
|
+
const read = (field: string): number => Number(input(root, field)?.value ?? 0);
|
|
29
|
+
return {
|
|
30
|
+
motorKv: read('motorKv'),
|
|
31
|
+
batteryVoltage: read('batteryVoltage'),
|
|
32
|
+
propellerPitchInches: read('propellerPitchInches'),
|
|
33
|
+
efficiencyPercent: read('efficiencyPercent'),
|
|
34
|
+
aircraftMassGrams: parseMassInput(input(root, 'aircraftMassGrams')?.value ?? '', unit),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function setInputs(root: HTMLElement, values: FpvDroneSpeedInputs, unit: DisplayUnit): void {
|
|
39
|
+
setInput(root, 'motorKv', String(values.motorKv));
|
|
40
|
+
setInput(root, 'batteryVoltage', String(values.batteryVoltage));
|
|
41
|
+
setInput(root, 'propellerPitchInches', String(values.propellerPitchInches));
|
|
42
|
+
setInput(root, 'efficiencyPercent', String(values.efficiencyPercent));
|
|
43
|
+
setInput(root, 'aircraftMassGrams', formatMassInput(values.aircraftMassGrams, unit));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function selectPreset(root: HTMLElement, id: string): void {
|
|
47
|
+
root.querySelectorAll<HTMLElement>('[data-preset]').forEach((element) => {
|
|
48
|
+
element.classList.toggle('is-selected', element.dataset.preset === id);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function selectUnit(root: HTMLElement, unit: DisplayUnit): void {
|
|
53
|
+
root.querySelectorAll<HTMLElement>('[data-unit]').forEach((element) => {
|
|
54
|
+
const active = element.dataset.unit === unit;
|
|
55
|
+
element.classList.toggle('is-selected', active);
|
|
56
|
+
element.setAttribute('aria-pressed', String(active));
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function recalculate(root: HTMLElement, ui: FpvDroneSpeedUI, unit: DisplayUnit): void {
|
|
61
|
+
const results = calculateFpvDroneSpeed(readInputs(root, unit));
|
|
62
|
+
renderResults({ root, results, ui, unit });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function changeUnit(root: HTMLElement, ui: FpvDroneSpeedUI, from: DisplayUnit, to: DisplayUnit): void {
|
|
66
|
+
if (from === to) return;
|
|
67
|
+
const values = readInputs(root, from);
|
|
68
|
+
setInputs(root, values, to);
|
|
69
|
+
root.dataset.unit = to;
|
|
70
|
+
selectUnit(root, to);
|
|
71
|
+
updateUnitLabels(root, to, ui);
|
|
72
|
+
recalculate(root, ui, to);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function readUi(root: HTMLElement): FpvDroneSpeedUI {
|
|
76
|
+
const element = root.querySelector<HTMLScriptElement>('[data-fpv-speed-ui]');
|
|
77
|
+
return JSON.parse(element?.textContent ?? '{}') as FpvDroneSpeedUI;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function initFpvDroneSpeedCalculator(root: HTMLElement): void {
|
|
81
|
+
const ui = readUi(root);
|
|
82
|
+
let unit: DisplayUnit = 'metric';
|
|
83
|
+
setInputs(root, DEFAULT_INPUTS, unit);
|
|
84
|
+
updateUnitLabels(root, unit, ui);
|
|
85
|
+
selectUnit(root, unit);
|
|
86
|
+
root.dataset.unit = unit;
|
|
87
|
+
|
|
88
|
+
root.querySelectorAll<HTMLElement>('[data-preset]').forEach((element) => element.addEventListener('click', () => {
|
|
89
|
+
const preset = PRESETS.find((item) => item.id === element.dataset.preset);
|
|
90
|
+
if (!preset) return;
|
|
91
|
+
setInputs(root, preset.inputs, unit);
|
|
92
|
+
selectPreset(root, preset.id);
|
|
93
|
+
recalculate(root, ui, unit);
|
|
94
|
+
}));
|
|
95
|
+
root.querySelectorAll<HTMLElement>('[data-unit]').forEach((element) => element.addEventListener('click', () => {
|
|
96
|
+
const next = element.dataset.unit === 'imperial' ? 'imperial' : 'metric';
|
|
97
|
+
changeUnit(root, ui, unit, next);
|
|
98
|
+
unit = next;
|
|
99
|
+
}));
|
|
100
|
+
root.querySelectorAll<HTMLInputElement>('input[data-field]').forEach((element) => element.addEventListener('input', () => {
|
|
101
|
+
selectPreset(root, '');
|
|
102
|
+
recalculate(root, ui, unit);
|
|
103
|
+
}));
|
|
104
|
+
recalculate(root, ui, unit);
|
|
105
|
+
}
|