routes: объём маршрута (volumeMb) — расчёт, хранение в БД и отображение

Объём режима = скорость × длительность, Мбайт: съёмка 120 Мбайт/с (12 ГБ / 100 с), сброс — канал 1200 Мбит/с ÷ 8. Единый источник — ModeDataRates в pcp-types-lib. Хранится колонкой volume_mb (Flyway V10 mission-planing, V9 complex-mission, бэкфилл из duration), заполняется на всех путях построения через @PrePersist/@PreUpdate. DTO отдают вычисляемое read-only поле (та же формула). Фронт pcp-tgu-ops-ui показывает объём в редакторе (инспектор, построенный маршрут) и в сводке комплексного плана.
This commit is contained in:
Дмитрий Соловьев
2026-06-06 18:34:29 +03:00
parent 316e0ee552
commit 2499aebf2d
20 changed files with 190 additions and 13 deletions
@@ -0,0 +1,31 @@
package space.nstart.pcp.pcp_types_lib.dto.satellite.mission
/**
* Скорости накопления/сброса данных для оценки объёма режима.
*
* Объём (`volumeMb`) считается единообразно во всех сервисах в **мегабайтах (Мбайт)** как
* `скорость × длительность`, поэтому константы живут в общей библиотеке типов — один источник
* правды для текущего плана (pcp-ui-service), редактора и комплексного плана (pcp-tgu-ops-ui).
*
* Внимание на единицы: съёмка задана в Мбайт/с, а скорость канала сброса — в Мбит/с,
* поэтому при расчёте объёма сброса скорость приводится к Мбайт делением на 8.
*/
object ModeDataRates {
/**
* Накопление при съёмке — единое для всех КА, **Мбайт/с**.
* Из расчёта 100 с маршрута = 12 ГБ (1 ГБ = 1000 Мбайт): 12000 Мбайт / 100 с = 120 Мбайт/с.
*/
const val SURVEY_MBYTE_PER_SEC: Double = 120.0
/** Сброс — постоянная скорость канала, **Мбит/с**. */
const val DROP_MBIT_PER_SEC: Double = 1200.0
/** Бит в байте — приведение скорости канала (Мбит/с) к объёму памяти (Мбайт). */
private const val BITS_PER_BYTE: Double = 8.0
/** Объём маршрута съёмки, Мбайт, по его длительности (секунды). */
fun surveyVolumeMb(durationSeconds: Double): Double = durationSeconds * SURVEY_MBYTE_PER_SEC
/** Объём сброса, Мбайт, по длительности сеанса (секунды): скорость канала Мбит/с → Мбайт. */
fun dropVolumeMb(durationSeconds: Double): Double = durationSeconds * DROP_MBIT_PER_SEC / BITS_PER_BYTE
}
@@ -1,5 +1,6 @@
package space.nstart.pcp.pcp_types_lib.dto.satellite.mission package space.nstart.pcp.pcp_types_lib.dto.satellite.mission
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.annotation.JsonSubTypes import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo import com.fasterxml.jackson.annotation.JsonTypeInfo
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
@@ -35,6 +36,10 @@ sealed interface ModeResponseDTO {
@get:Schema(description = "Тип режима", allowableValues = ["SURVEY", "DROP"]) @get:Schema(description = "Тип режима", allowableValues = ["SURVEY", "DROP"])
val type: String val type: String
@get:Schema(description = "Объём режима, Мбайт (накопление съёмки 120 Мбайт/с, сброс — канал 1200 Мбит/с)")
@get:JsonProperty(access = JsonProperty.Access.READ_ONLY)
val volumeMb: Double
} }
@Schema(description = "Режим съемки") @Schema(description = "Режим съемки")
@@ -54,7 +59,10 @@ data class SurveyModeResponseDTO(
val roll: Double = 0.0, val roll: Double = 0.0,
@get:Schema(description = "Идентификаторы забронированных слотов") @get:Schema(description = "Идентификаторы забронированных слотов")
val bookedSlotIds: List<Long> = emptyList() val bookedSlotIds: List<Long> = emptyList()
) : ModeResponseDTO ) : ModeResponseDTO {
@get:JsonProperty(access = JsonProperty.Access.READ_ONLY)
override val volumeMb: Double get() = ModeDataRates.surveyVolumeMb(duration)
}
@Schema(description = "Режим сброса") @Schema(description = "Режим сброса")
data class DropModeResponseDTO( data class DropModeResponseDTO(
@@ -66,4 +74,7 @@ data class DropModeResponseDTO(
val station: String? = "", val station: String? = "",
val duration: Double = 0.0, val duration: Double = 0.0,
val surveys: List<Long> = emptyList() val surveys: List<Long> = emptyList()
) : ModeResponseDTO ) : ModeResponseDTO {
@get:JsonProperty(access = JsonProperty.Access.READ_ONLY)
override val volumeMb: Double get() = ModeDataRates.dropVolumeMb(duration)
}
@@ -1,5 +1,6 @@
package space.nstart.pcp.pcp_types_lib.dto.satellite.mission package space.nstart.pcp.pcp_types_lib.dto.satellite.mission
import com.fasterxml.jackson.annotation.JsonProperty
import java.time.LocalDateTime import java.time.LocalDateTime
data class SatelliteModeResponseDTO( data class SatelliteModeResponseDTO(
@@ -19,4 +20,9 @@ data class SatelliteModeResponseDTO(
val cellNums: List<Long> = emptyList(), val cellNums: List<Long> = emptyList(),
val bookedSlotIds: List<Long> = emptyList(), val bookedSlotIds: List<Long> = emptyList(),
val snapshotId: Long? = null val snapshotId: Long? = null
) ) {
/** Объём режима, Мбайт: сброс — канал 1200 Мбит/с (÷8), иначе — накопление съёмки 120 Мбайт/с. */
@get:JsonProperty(access = JsonProperty.Access.READ_ONLY)
val volumeMb: Double
get() = if (type == "DROP") ModeDataRates.dropVolumeMb(duration) else ModeDataRates.surveyVolumeMb(duration)
}
@@ -1,5 +1,6 @@
package space.nstart.pcp.pcp_types_lib.dto.satellite.mission package space.nstart.pcp.pcp_types_lib.dto.satellite.mission
import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.DecimalMin import jakarta.validation.constraints.DecimalMin
import java.time.LocalDateTime import java.time.LocalDateTime
@@ -74,4 +75,8 @@ data class SurveyRouteBuildResponseDTO(
val contourWkt: String, val contourWkt: String,
@field:Schema(description = "Идентификатор сохранённого режима съёмки в БД (null — маршрут не сохранялся)") @field:Schema(description = "Идентификатор сохранённого режима съёмки в БД (null — маршрут не сохранялся)")
val surveyModeId: Long? = null val surveyModeId: Long? = null
) ) {
@get:Schema(description = "Объём маршрута, Мбайт (накопление съёмки 120 Мбайт/с × длительность)")
@get:JsonProperty(access = JsonProperty.Access.READ_ONLY)
val volumeMb: Double get() = ModeDataRates.surveyVolumeMb(duration)
}
@@ -6,6 +6,7 @@ import com.fasterxml.jackson.module.kotlin.readValue
import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.api.Assertions.assertInstanceOf
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import kotlin.test.assertTrue
class ModeResponseDTOJacksonTest { class ModeResponseDTOJacksonTest {
@@ -50,4 +51,26 @@ class ModeResponseDTOJacksonTest {
assertEquals("SURVEY", result[0].type) assertEquals("SURVEY", result[0].type)
assertEquals("DROP", result[1].type) assertEquals("DROP", result[1].type)
} }
@Test
fun `serializes computed volume and tolerates it on read-back`() {
// Сервис-источник теперь сериализует volumeMb; потребитель (pcp-ui-service)
// десериализует тот же JSON обратно — read-only поле не должно ломать чтение.
val modes: List<ModeResponseDTO> = listOf(
SurveyModeResponseDTO(id = 1, planId = 10, duration = 100.0),
DropModeResponseDTO(id = 2, planId = 10, duration = 5.0)
)
val json = mapper.writeValueAsString(modes)
val tree = mapper.readTree(json)
// Съёмка: 100 с × 120 Мбайт/с = 12000 Мбайт. Сброс: 5 с × 1200 Мбит/с ÷ 8 = 750 Мбайт.
assertEquals(12000.0, tree[0]["volumeMb"].asDouble())
assertEquals(750.0, tree[1]["volumeMb"].asDouble())
val roundTrip: List<ModeResponseDTO> = mapper.readValue(json)
assertEquals(12000.0, roundTrip[0].volumeMb)
assertEquals(750.0, roundTrip[1].volumeMb)
assertTrue(roundTrip[0] is SurveyModeResponseDTO)
}
} }
@@ -12,8 +12,11 @@ import jakarta.persistence.Id
import jakarta.persistence.OneToMany import jakarta.persistence.OneToMany
import jakarta.persistence.JoinColumn import jakarta.persistence.JoinColumn
import jakarta.persistence.ManyToOne import jakarta.persistence.ManyToOne
import jakarta.persistence.PrePersist
import jakarta.persistence.PreUpdate
import jakarta.persistence.Table import jakarta.persistence.Table
import jakarta.persistence.Index import jakarta.persistence.Index
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeDataRates
import java.time.LocalDateTime import java.time.LocalDateTime
@Entity @Entity
@@ -92,4 +95,14 @@ class SatelliteModeEntity(
fetch = FetchType.LAZY fetch = FetchType.LAZY
) )
var cells: MutableList<SatelliteModeCellEntity> = mutableListOf() var cells: MutableList<SatelliteModeCellEntity> = mutableListOf()
) ) {
/** Объём маршрута, Мбайт. Денормализация: считается из duration при построении (satellite_modes — только SURVEY). */
@Column(name = "volume_mb", nullable = false)
var volumeMb: Double = ModeDataRates.surveyVolumeMb(duration)
@PrePersist
@PreUpdate
fun computeVolumeMb() {
volumeMb = ModeDataRates.surveyVolumeMb(duration)
}
}
@@ -0,0 +1,6 @@
-- Объём маршрута съёмки как атрибут, Мбайт. Считается при построении из длительности
-- (см. ModeDataRates в pcp-types-lib): накопление 120 Мбайт/с. satellite_modes — только SURVEY.
ALTER TABLE satellite_modes ADD COLUMN volume_mb DOUBLE PRECISION NOT NULL DEFAULT 0;
-- Бэкфилл существующих маршрутов из длительности.
UPDATE satellite_modes SET volume_mb = duration * 120;
@@ -3,7 +3,10 @@ package space.nstart.pcp.pcp_mission_planing_service.entity
import jakarta.persistence.Column import jakarta.persistence.Column
import jakarta.persistence.DiscriminatorValue import jakarta.persistence.DiscriminatorValue
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.PrePersist
import jakarta.persistence.PreUpdate
import jakarta.persistence.Table import jakarta.persistence.Table
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeDataRates
import java.time.LocalDateTime import java.time.LocalDateTime
@Entity @Entity
@@ -20,4 +23,14 @@ class DropModeEntity(
@Column(name = "duration", nullable = false) @Column(name = "duration", nullable = false)
var duration: Double = 0.0 var duration: Double = 0.0
) : ModeEntity(id, plan, timeStart, revolution) ) : ModeEntity(id, plan, timeStart, revolution) {
/** Объём сброса, Мбайт. Денормализация: считается из duration (канал 1200 Мбит/с ÷ 8) при сохранении. */
@Column(name = "volume_mb", nullable = false)
var volumeMb: Double = ModeDataRates.dropVolumeMb(duration)
@PrePersist
@PreUpdate
fun computeVolumeMb() {
volumeMb = ModeDataRates.dropVolumeMb(duration)
}
}
@@ -5,8 +5,11 @@ import jakarta.persistence.DiscriminatorValue
import jakarta.persistence.Entity import jakarta.persistence.Entity
import jakarta.persistence.EnumType import jakarta.persistence.EnumType
import jakarta.persistence.Enumerated import jakarta.persistence.Enumerated
import jakarta.persistence.PrePersist
import jakarta.persistence.PreUpdate
import jakarta.persistence.Table import jakarta.persistence.Table
import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus import space.nstart.pcp.pcp_types_lib.dto.routes.SurveyModeStatus
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeDataRates
import java.time.LocalDateTime import java.time.LocalDateTime
@Entity @Entity
@@ -46,4 +49,14 @@ class SurveyModeEntity(
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
@Column(name = "source", nullable = false, length = 20) @Column(name = "source", nullable = false, length = 20)
var source: SurveyModeSource = SurveyModeSource.SLOT var source: SurveyModeSource = SurveyModeSource.SLOT
) : ModeEntity(id, plan, timeStart, revolution) ) : ModeEntity(id, plan, timeStart, revolution) {
/** Объём маршрута съёмки, Мбайт. Денормализация: считается из duration при построении/сохранении. */
@Column(name = "volume_mb", nullable = false)
var volumeMb: Double = ModeDataRates.surveyVolumeMb(duration)
@PrePersist
@PreUpdate
fun computeVolumeMb() {
volumeMb = ModeDataRates.surveyVolumeMb(duration)
}
}
@@ -0,0 +1,8 @@
-- Объём данных как атрибут маршрута, Мбайт. Считается при построении из длительности
-- (см. ModeDataRates в pcp-types-lib): съёмка 120 Мбайт/с; сброс — канал 1200 Мбит/с ÷ 8 = 150 Мбайт/с.
ALTER TABLE survey_modes ADD COLUMN volume_mb DOUBLE PRECISION NOT NULL DEFAULT 0;
ALTER TABLE drop_modes ADD COLUMN volume_mb DOUBLE PRECISION NOT NULL DEFAULT 0;
-- Бэкфилл существующих маршрутов из длительности.
UPDATE survey_modes SET volume_mb = duration * 120;
UPDATE drop_modes SET volume_mb = duration * 1200.0 / 8;
@@ -99,6 +99,8 @@ export type ComplexPlanMode = {
cellNum: number; cellNum: number;
bookedSlotIds: number[]; bookedSlotIds: number[];
snapshotId: number | null; snapshotId: number | null;
/** Объём режима, МБ (скорость накопления/сброса × длительность). Считает бэк (pcp-types-lib). */
volumeMb?: number;
}; };
/** /**
@@ -46,6 +46,8 @@ export type SurveyRoute = {
longitude: number; longitude: number;
/** Контур полосы съёмки, WKT POLYGON (lon lat, ...). */ /** Контур полосы съёмки, WKT POLYGON (lon lat, ...). */
contourWkt: string; contourWkt: string;
/** Объём маршрута, МБ (скорость накопления × длительность). Считает бэк (pcp-types-lib). */
volumeMb?: number;
/** Идентификатор сохранённого режима съёмки в БД (есть только после сохранения). */ /** Идентификатор сохранённого режима съёмки в БД (есть только после сохранения). */
surveyModeId?: number; surveyModeId?: number;
}; };
@@ -96,6 +98,8 @@ export type DropMode = {
station: string | null; station: string | null;
duration: number; duration: number;
surveys: number[]; surveys: number[];
/** Объём сброса, Мбайт (канал 1200 Мбит/с ÷ 8 × длительность). Считает бэк (pcp-types-lib). */
volumeMb?: number;
}; };
/** /**
@@ -18,6 +18,7 @@ import type { Tgu2DMapLayersState } from "../tgu-map-2d/model/mapLayerTypes";
import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes"; import type { MapPolygon, Tgu2DMapScene } from "../tgu-map-2d/model/mapTypes";
import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView"; import { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime"; import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { formatVolume } from "../../utils/dataVolume";
type TguComplexPlanTabProps = { type TguComplexPlanTabProps = {
range: TimelineRange; range: TimelineRange;
@@ -203,6 +204,8 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
const displayRun = selectedRun; const displayRun = selectedRun;
const contoursCount = modePolygons.length; const contoursCount = modePolygons.length;
// Суммарный объём режимов run’а (МБ) — съёмка по скорости накопления, сброс по скорости канала.
const totalVolumeMb = modes.reduce((sum, mode) => sum + (mode.volumeMb ?? 0), 0);
return ( return (
<main className="tgu-map-workspace"> <main className="tgu-map-workspace">
@@ -253,7 +256,7 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} /> <div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
{displayRun && ( {displayRun && (
<div className="tgu-map-toolbar__subtitle"> <div className="tgu-map-toolbar__subtitle">
run #{displayRun.runId} · режимов {displayRun.calculatedModesCount} · охват {formatPercent(displayRun.coveredAreaPercent)} run #{displayRun.runId} · режимов {displayRun.calculatedModesCount} · охват {formatPercent(displayRun.coveredAreaPercent)} · объём {formatVolume(totalVolumeMb)}
{displayRun.snapshotIds.length > 0 ? ` · snapshot ${displayRun.snapshotIds.join(", ")}` : ""} · на карте {contoursCount} контуров {displayRun.snapshotIds.length > 0 ? ` · snapshot ${displayRun.snapshotIds.join(", ")}` : ""} · на карте {contoursCount} контуров
{showRequests ? ` · заявок ${requestItems.length}` : ""} {showRequests ? ` · заявок ${requestItems.length}` : ""}
</div> </div>
@@ -1,5 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { formatDate, formatDateTime, formatDateTimeSeconds, formatDuration, formatTime, kindColor, kindLabel } from "./editorPresentation"; import { formatDate, formatDateTime, formatDateTimeSeconds, formatDuration, formatTime, formatVolume, kindColor, kindLabel } from "./editorPresentation";
import { statusStyle } from "../tgu-planning/tguStatus"; import { statusStyle } from "../tgu-planning/tguStatus";
import { formatLocalDateTime, parseLocal, toLocalIso } from "../../utils/localTime"; import { formatLocalDateTime, parseLocal, toLocalIso } from "../../utils/localTime";
import { Time24Field } from "../../components/timeFields"; import { Time24Field } from "../../components/timeFields";
@@ -172,6 +172,8 @@ export function WorkInspector({
<dd className="mono">{formatDateTimeSeconds(work.endMs)}</dd> <dd className="mono">{formatDateTimeSeconds(work.endMs)}</dd>
<dt>Длительность</dt> <dt>Длительность</dt>
<dd className="mono">{formatDuration(work.endMs - work.startMs)}</dd> <dd className="mono">{formatDuration(work.endMs - work.startMs)}</dd>
<dt>Объём</dt>
<dd className="mono">{formatVolume(work.volumeMb)}</dd>
</dl> </dl>
</section> </section>
@@ -427,7 +429,7 @@ export function ShootPanel({
<div className="tgu-alert tgu-alert--danger tgu-alert--compact">{routeError}</div> <div className="tgu-alert tgu-alert--danger tgu-alert--compact">{routeError}</div>
) : route ? ( ) : route ? (
<div className="tgu-editor-panel__hint"> <div className="tgu-editor-panel__hint">
Маршрут построен · виток {route.revolution} · крен {route.roll.toFixed(1)}° · захват {route.captureAngle.toFixed(1)}° Маршрут построен · виток {route.revolution} · крен {route.roll.toFixed(1)}° · захват {route.captureAngle.toFixed(1)}° · объём {formatVolume(route.volumeMb)}
<br /> <br />
центр {route.lat.toFixed(2)}, {route.longitude.toFixed(2)} · контур показан на карте центр {route.lat.toFixed(2)}, {route.longitude.toFixed(2)} · контур показан на карте
</div> </div>
@@ -741,7 +741,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
rollDeg: savedRoute?.roll ?? routeRoll, rollDeg: savedRoute?.roll ?? routeRoll,
captureAngleDeg: savedRoute?.captureAngle ?? DEFAULT_CAPTURE_ANGLE_DEG, captureAngleDeg: savedRoute?.captureAngle ?? DEFAULT_CAPTURE_ANGLE_DEG,
centerLat: savedRoute?.lat, centerLat: savedRoute?.lat,
centerLon: savedRoute?.longitude centerLon: savedRoute?.longitude,
volumeMb: savedRoute?.volumeMb
}) })
); );
setEditingWorkId(undefined); setEditingWorkId(undefined);
@@ -788,6 +789,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setDropSaving(true); setDropSaving(true);
setDropError(undefined); setDropError(undefined);
let savedDropId: number | undefined; let savedDropId: number | undefined;
let savedDropVolumeMb: number | undefined;
try { try {
// Ленивое создание черновика на первую правку; адресуем сброс в DRAFT, а не в живой план. // Ленивое создание черновика на первую правку; адресуем сброс в DRAFT, а не в живой план.
const draftId = await ensureDraft(); const draftId = await ensureDraft();
@@ -806,6 +808,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
planEnd: toLocalIso(selectedPlan.endMs) planEnd: toLocalIso(selectedPlan.endMs)
}); });
savedDropId = savedDrop.id; savedDropId = savedDrop.id;
savedDropVolumeMb = savedDrop.volumeMb;
// Перечитываем включения DRAFT (источник истины) и ревалидируем. // Перечитываем включения DRAFT (источник истины) и ревалидируем.
void refreshDraftModes(draftId); void refreshDraftModes(draftId);
} catch (error) { } catch (error) {
@@ -824,7 +827,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
endMs: zoneStopMsVal, endMs: zoneStopMsVal,
label: `Сброс · ${stationNm}`, label: `Сброс · ${stationNm}`,
station: { id: String(selectedZone.stationId), name: stationNm }, station: { id: String(selectedZone.stationId), name: stationNm },
planModeId: savedDropId planModeId: savedDropId,
volumeMb: savedDropVolumeMb
}) })
); );
setEditingWorkId(undefined); setEditingWorkId(undefined);
@@ -10,6 +10,8 @@ export type PlanSurveyMode = {
duration: number; duration: number;
contourWkt: string | null; contourWkt: string | null;
roll: number; roll: number;
/** Объём маршрута, МБ (скорость накопления × длительность). Считает бэк (pcp-types-lib). */
volumeMb?: number;
}; };
export type PlanDropMode = { export type PlanDropMode = {
@@ -21,6 +23,8 @@ export type PlanDropMode = {
station: string | null; station: string | null;
duration: number; duration: number;
surveys: number[]; surveys: number[];
/** Объём сброса, Мбайт (канал 1200 Мбит/с ÷ 8 × длительность). Считает бэк (pcp-types-lib). */
volumeMb?: number;
}; };
export type PlanMode = PlanSurveyMode | PlanDropMode; export type PlanMode = PlanSurveyMode | PlanDropMode;
@@ -29,6 +29,8 @@ export type AddShootInput = {
captureAngleDeg?: number; captureAngleDeg?: number;
centerLat?: number; centerLat?: number;
centerLon?: number; centerLon?: number;
/** Объём маршрута, МБ (из ответа бэка). */
volumeMb?: number;
}; };
export type AddDownlinkInput = { export type AddDownlinkInput = {
@@ -39,6 +41,8 @@ export type AddDownlinkInput = {
station: EditorGroundStation; station: EditorGroundStation;
/** id сохранённого режима сброса в БД черновика (если сброс персистнут). */ /** id сохранённого режима сброса в БД черновика (если сброс персистнут). */
planModeId?: number; planModeId?: number;
/** Объём сброса, МБ (из ответа бэка). */
volumeMb?: number;
}; };
export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft { export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft {
@@ -80,6 +84,7 @@ export function planModesToWorks(modes: PlanMode[]): EditorWork[] {
rollDeg: mode.roll, rollDeg: mode.roll,
centerLat: mode.lat, centerLat: mode.lat,
centerLon: mode.longitude, centerLon: mode.longitude,
volumeMb: mode.volumeMb,
// Центр контура — он же опорная точка для проверки видимости. // Центр контура — он же опорная точка для проверки видимости.
targetLat: mode.lat, targetLat: mode.lat,
targetLon: mode.longitude targetLon: mode.longitude
@@ -96,7 +101,8 @@ export function planModesToWorks(modes: PlanMode[]): EditorWork[] {
planModeId: mode.id, planModeId: mode.id,
revolution: mode.revolution, revolution: mode.revolution,
stationId: mode.station ?? undefined, stationId: mode.station ?? undefined,
surveyIds: mode.surveys surveyIds: mode.surveys,
volumeMb: mode.volumeMb
}]; }];
}); });
} }
@@ -160,6 +166,7 @@ export function addShoot(draft: Draft, input: AddShootInput): Draft {
captureAngleDeg: input.captureAngleDeg, captureAngleDeg: input.captureAngleDeg,
centerLat: input.centerLat, centerLat: input.centerLat,
centerLon: input.centerLon, centerLon: input.centerLon,
volumeMb: input.volumeMb,
origin: "added", origin: "added",
isNew: true isNew: true
}; };
@@ -176,6 +183,7 @@ export function addDownlink(draft: Draft, input: AddDownlinkInput): Draft {
endMs: input.endMs, endMs: input.endMs,
stationId: input.station.id, stationId: input.station.id,
planModeId: input.planModeId, planModeId: input.planModeId,
volumeMb: input.volumeMb,
origin: "added", origin: "added",
isNew: true isNew: true
}; };
@@ -1,5 +1,8 @@
import type { EditorWorkKind } from "./model/editorTypes"; import type { EditorWorkKind } from "./model/editorTypes";
// Объём данных — общий форматтер (используется и в комплексном плане).
export { formatVolume } from "../../utils/dataVolume";
export const EDITOR_MONTHS = ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"]; export const EDITOR_MONTHS = ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"];
export const DEFAULT_EDITOR_MODES = [ export const DEFAULT_EDITOR_MODES = [
@@ -12,6 +12,8 @@ export type EditorWorkBase = {
endMs: number; endMs: number;
origin: EditorWorkOrigin; origin: EditorWorkOrigin;
isNew?: boolean; isNew?: boolean;
/** Объём включения, МБ (считает бэк: скорость × длительность). Есть у включений плана и сохранённых правок. */
volumeMb?: number;
}; };
export type EditorShootWork = EditorWorkBase & { export type EditorShootWork = EditorWorkBase & {
@@ -0,0 +1,16 @@
/**
* Форматирование объёма данных режима (съёмка/сброс). Бэк отдаёт объём в мегабайтах
* (см. ModeDataRates в pcp-types-lib: накопление съёмки 120 Мбайт/с, сброс — канал 1200 Мбит/с ÷ 8).
*/
/** До 1000 — в МБ, дальше — в ГБ (1 ГБ = 1000 МБ). Тут МБ = мегабайт. «—», если значения нет. */
export function formatVolume(mb?: number | null): string {
if (mb == null || !Number.isFinite(mb) || mb <= 0) {
return "—";
}
if (mb >= 1000) {
const gb = mb / 1000;
return `${gb >= 100 ? Math.round(gb) : gb.toFixed(1)} ГБ`;
}
return `${Math.round(mb)} МБ`;
}