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
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.annotation.JsonSubTypes
import com.fasterxml.jackson.annotation.JsonTypeInfo
import io.swagger.v3.oas.annotations.media.Schema
@@ -35,6 +36,10 @@ sealed interface ModeResponseDTO {
@get:Schema(description = "Тип режима", allowableValues = ["SURVEY", "DROP"])
val type: String
@get:Schema(description = "Объём режима, Мбайт (накопление съёмки 120 Мбайт/с, сброс — канал 1200 Мбит/с)")
@get:JsonProperty(access = JsonProperty.Access.READ_ONLY)
val volumeMb: Double
}
@Schema(description = "Режим съемки")
@@ -54,7 +59,10 @@ data class SurveyModeResponseDTO(
val roll: Double = 0.0,
@get:Schema(description = "Идентификаторы забронированных слотов")
val bookedSlotIds: List<Long> = emptyList()
) : ModeResponseDTO
) : ModeResponseDTO {
@get:JsonProperty(access = JsonProperty.Access.READ_ONLY)
override val volumeMb: Double get() = ModeDataRates.surveyVolumeMb(duration)
}
@Schema(description = "Режим сброса")
data class DropModeResponseDTO(
@@ -66,4 +74,7 @@ data class DropModeResponseDTO(
val station: String? = "",
val duration: Double = 0.0,
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
import com.fasterxml.jackson.annotation.JsonProperty
import java.time.LocalDateTime
data class SatelliteModeResponseDTO(
@@ -19,4 +20,9 @@ data class SatelliteModeResponseDTO(
val cellNums: List<Long> = emptyList(),
val bookedSlotIds: List<Long> = emptyList(),
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
import com.fasterxml.jackson.annotation.JsonProperty
import io.swagger.v3.oas.annotations.media.Schema
import jakarta.validation.constraints.DecimalMin
import java.time.LocalDateTime
@@ -74,4 +75,8 @@ data class SurveyRouteBuildResponseDTO(
val contourWkt: String,
@field:Schema(description = "Идентификатор сохранённого режима съёмки в БД (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.assertInstanceOf
import org.junit.jupiter.api.Test
import kotlin.test.assertTrue
class ModeResponseDTOJacksonTest {
@@ -50,4 +51,26 @@ class ModeResponseDTOJacksonTest {
assertEquals("SURVEY", result[0].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.JoinColumn
import jakarta.persistence.ManyToOne
import jakarta.persistence.PrePersist
import jakarta.persistence.PreUpdate
import jakarta.persistence.Table
import jakarta.persistence.Index
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeDataRates
import java.time.LocalDateTime
@Entity
@@ -92,4 +95,14 @@ class SatelliteModeEntity(
fetch = FetchType.LAZY
)
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.DiscriminatorValue
import jakarta.persistence.Entity
import jakarta.persistence.PrePersist
import jakarta.persistence.PreUpdate
import jakarta.persistence.Table
import space.nstart.pcp.pcp_types_lib.dto.satellite.mission.ModeDataRates
import java.time.LocalDateTime
@Entity
@@ -20,4 +23,14 @@ class DropModeEntity(
@Column(name = "duration", nullable = false)
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.EnumType
import jakarta.persistence.Enumerated
import jakarta.persistence.PrePersist
import jakarta.persistence.PreUpdate
import jakarta.persistence.Table
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
@Entity
@@ -46,4 +49,14 @@ class SurveyModeEntity(
@Enumerated(EnumType.STRING)
@Column(name = "source", nullable = false, length = 20)
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;
bookedSlotIds: number[];
snapshotId: number | null;
/** Объём режима, МБ (скорость накопления/сброса × длительность). Считает бэк (pcp-types-lib). */
volumeMb?: number;
};
/**
@@ -46,6 +46,8 @@ export type SurveyRoute = {
longitude: number;
/** Контур полосы съёмки, WKT POLYGON (lon lat, ...). */
contourWkt: string;
/** Объём маршрута, МБ (скорость накопления × длительность). Считает бэк (pcp-types-lib). */
volumeMb?: number;
/** Идентификатор сохранённого режима съёмки в БД (есть только после сохранения). */
surveyModeId?: number;
};
@@ -96,6 +98,8 @@ export type DropMode = {
station: string | null;
duration: 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 { Tgu2DMapView } from "../tgu-map-2d/Tgu2DMapView";
import { formatLocalDateTime, parseLocal, toLocalInputValue, toLocalIso } from "../../utils/localTime";
import { formatVolume } from "../../utils/dataVolume";
type TguComplexPlanTabProps = {
range: TimelineRange;
@@ -203,6 +204,8 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
const displayRun = selectedRun;
const contoursCount = modePolygons.length;
// Суммарный объём режимов run’а (МБ) — съёмка по скорости накопления, сброс по скорости канала.
const totalVolumeMb = modes.reduce((sum, mode) => sum + (mode.volumeMb ?? 0), 0);
return (
<main className="tgu-map-workspace">
@@ -253,7 +256,7 @@ export function TguComplexPlanTab({ range, platforms }: TguComplexPlanTabProps)
<div className="tgu-map-toolbar__spacer" style={{ flex: 1 }} />
{displayRun && (
<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} контуров
{showRequests ? ` · заявок ${requestItems.length}` : ""}
</div>
@@ -1,5 +1,5 @@
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 { formatLocalDateTime, parseLocal, toLocalIso } from "../../utils/localTime";
import { Time24Field } from "../../components/timeFields";
@@ -172,6 +172,8 @@ export function WorkInspector({
<dd className="mono">{formatDateTimeSeconds(work.endMs)}</dd>
<dt>Длительность</dt>
<dd className="mono">{formatDuration(work.endMs - work.startMs)}</dd>
<dt>Объём</dt>
<dd className="mono">{formatVolume(work.volumeMb)}</dd>
</dl>
</section>
@@ -427,7 +429,7 @@ export function ShootPanel({
<div className="tgu-alert tgu-alert--danger tgu-alert--compact">{routeError}</div>
) : route ? (
<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 />
центр {route.lat.toFixed(2)}, {route.longitude.toFixed(2)} · контур показан на карте
</div>
@@ -741,7 +741,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
rollDeg: savedRoute?.roll ?? routeRoll,
captureAngleDeg: savedRoute?.captureAngle ?? DEFAULT_CAPTURE_ANGLE_DEG,
centerLat: savedRoute?.lat,
centerLon: savedRoute?.longitude
centerLon: savedRoute?.longitude,
volumeMb: savedRoute?.volumeMb
})
);
setEditingWorkId(undefined);
@@ -788,6 +789,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
setDropSaving(true);
setDropError(undefined);
let savedDropId: number | undefined;
let savedDropVolumeMb: number | undefined;
try {
// Ленивое создание черновика на первую правку; адресуем сброс в DRAFT, а не в живой план.
const draftId = await ensureDraft();
@@ -806,6 +808,7 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
planEnd: toLocalIso(selectedPlan.endMs)
});
savedDropId = savedDrop.id;
savedDropVolumeMb = savedDrop.volumeMb;
// Перечитываем включения DRAFT (источник истины) и ревалидируем.
void refreshDraftModes(draftId);
} catch (error) {
@@ -824,7 +827,8 @@ export function TguEditorTab({ selectedSpacecraftId, selectedPlan, appliedRange,
endMs: zoneStopMsVal,
label: `Сброс · ${stationNm}`,
station: { id: String(selectedZone.stationId), name: stationNm },
planModeId: savedDropId
planModeId: savedDropId,
volumeMb: savedDropVolumeMb
})
);
setEditingWorkId(undefined);
@@ -10,6 +10,8 @@ export type PlanSurveyMode = {
duration: number;
contourWkt: string | null;
roll: number;
/** Объём маршрута, МБ (скорость накопления × длительность). Считает бэк (pcp-types-lib). */
volumeMb?: number;
};
export type PlanDropMode = {
@@ -21,6 +23,8 @@ export type PlanDropMode = {
station: string | null;
duration: number;
surveys: number[];
/** Объём сброса, Мбайт (канал 1200 Мбит/с ÷ 8 × длительность). Считает бэк (pcp-types-lib). */
volumeMb?: number;
};
export type PlanMode = PlanSurveyMode | PlanDropMode;
@@ -29,6 +29,8 @@ export type AddShootInput = {
captureAngleDeg?: number;
centerLat?: number;
centerLon?: number;
/** Объём маршрута, МБ (из ответа бэка). */
volumeMb?: number;
};
export type AddDownlinkInput = {
@@ -39,6 +41,8 @@ export type AddDownlinkInput = {
station: EditorGroundStation;
/** id сохранённого режима сброса в БД черновика (если сброс персистнут). */
planModeId?: number;
/** Объём сброса, МБ (из ответа бэка). */
volumeMb?: number;
};
export function seedDraft(plan?: TguPlanUi, spacecraftId?: string): Draft {
@@ -80,6 +84,7 @@ export function planModesToWorks(modes: PlanMode[]): EditorWork[] {
rollDeg: mode.roll,
centerLat: mode.lat,
centerLon: mode.longitude,
volumeMb: mode.volumeMb,
// Центр контура — он же опорная точка для проверки видимости.
targetLat: mode.lat,
targetLon: mode.longitude
@@ -96,7 +101,8 @@ export function planModesToWorks(modes: PlanMode[]): EditorWork[] {
planModeId: mode.id,
revolution: mode.revolution,
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,
centerLat: input.centerLat,
centerLon: input.centerLon,
volumeMb: input.volumeMb,
origin: "added",
isNew: true
};
@@ -176,6 +183,7 @@ export function addDownlink(draft: Draft, input: AddDownlinkInput): Draft {
endMs: input.endMs,
stationId: input.station.id,
planModeId: input.planModeId,
volumeMb: input.volumeMb,
origin: "added",
isNew: true
};
@@ -1,5 +1,8 @@
import type { EditorWorkKind } from "./model/editorTypes";
// Объём данных — общий форматтер (используется и в комплексном плане).
export { formatVolume } from "../../utils/dataVolume";
export const EDITOR_MONTHS = ["янв", "фев", "мар", "апр", "май", "июн", "июл", "авг", "сен", "окт", "ноя", "дек"];
export const DEFAULT_EDITOR_MODES = [
@@ -12,6 +12,8 @@ export type EditorWorkBase = {
endMs: number;
origin: EditorWorkOrigin;
isNew?: boolean;
/** Объём включения, МБ (считает бэк: скорость × длительность). Есть у включений плана и сохранённых правок. */
volumeMb?: number;
};
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)} МБ`;
}