This commit is contained in:
che
2026-07-12 20:42:21 +05:00
parent b815cde04c
commit e3490e5d2e
23 changed files with 1568 additions and 538 deletions
+117 -20
View File
@@ -1,12 +1,14 @@
package analize package analize
import ( import (
"errors"
"fmt" "fmt"
"math"
"net/http" "net/http"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"control/control_case"
"github.com/che4web/go4rest" "github.com/che4web/go4rest"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
@@ -32,10 +34,19 @@ type AnalizeSeriesPoint struct {
PsiMax float64 `json:"psi_max"` PsiMax float64 `json:"psi_max"`
} }
type AnalizeSeriesGroup struct {
GroupValue float64 `json:"group_value"`
GroupLabel string `json:"group_label"`
Points []AnalizeSeriesPoint `json:"points"`
}
type AnalizeSeriesResponse struct { type AnalizeSeriesResponse struct {
Parameter string `json:"parameter"` Parameter string `json:"parameter"`
Label string `json:"label"` Label string `json:"label"`
Points []AnalizeSeriesPoint `json:"points"` GroupParameter string `json:"group_parameter,omitempty"`
GroupLabel string `json:"group_label,omitempty"`
Points []AnalizeSeriesPoint `json:"points,omitempty"`
Groups []AnalizeSeriesGroup `json:"groups,omitempty"`
} }
var analizeParameterColumns = map[string]struct { var analizeParameterColumns = map[string]struct {
@@ -45,7 +56,8 @@ var analizeParameterColumns = map[string]struct {
"Rel": {Column: "rel", Label: "Rel"}, "Rel": {Column: "rel", Label: "Rel"},
"RelC": {Column: "rel_c", Label: "RelC"}, "RelC": {Column: "rel_c", Label: "RelC"},
"Le": {Column: "le", Label: "Le"}, "Le": {Column: "le", Label: "Le"},
"Pr": {Column: "pr", Label: "Pr"}, "Pr": {Column: "sc", Label: "Sc"},
"Sc": {Column: "sc", Label: "Sc"},
"Pe": {Column: "pe", Label: "Pe"}, "Pe": {Column: "pe", Label: "Pe"},
"Ma": {Column: "ma", Label: "Ma"}, "Ma": {Column: "ma", Label: "Ma"},
"Time": {Column: "time", Label: "Time"}, "Time": {Column: "time", Label: "Time"},
@@ -59,6 +71,13 @@ func (c *AnalizeController) Series(ctx *gin.Context) {
return return
} }
groupParam := ctx.Query("group_parameter")
groupSelected, groupOK := analizeParameterColumns[groupParam]
if groupParam != "" && !groupOK {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid group parameter"})
return
}
filterParam := ctx.Query("filter_parameter") filterParam := ctx.Query("filter_parameter")
filterSelected, filterOK := analizeParameterColumns[filterParam] filterSelected, filterOK := analizeParameterColumns[filterParam]
filterMin := ctx.Query("filter_min") filterMin := ctx.Query("filter_min")
@@ -67,17 +86,19 @@ func (c *AnalizeController) Series(ctx *gin.Context) {
type row struct { type row struct {
CaseID uint `gorm:"column:case_id"` CaseID uint `gorm:"column:case_id"`
CaseName string `gorm:"column:case_name"` CaseName string `gorm:"column:case_name"`
GroupVal float64 `gorm:"column:group_val"`
X float64 `gorm:"column:x"` X float64 `gorm:"column:x"`
Omega float64 `gorm:"column:omega"` Omega float64 `gorm:"column:omega"`
PsiMax float64 `gorm:"column:psi_max"` PsiMax float64 `gorm:"column:psi_max"`
} }
groupExpr := groupExpr(groupParam, groupSelected.Column)
query := fmt.Sprintf(` query := fmt.Sprintf(`
SELECT a.case_id, a.case_name, p.%s AS x, a.omega, a.psi_max SELECT a.case_id, a.case_name, %s AS group_val, p.%s AS x, a.omega, a.psi_max
FROM analizes a FROM analizes a
JOIN control_cases c ON c.id = a.case_id JOIN control_cases c ON c.id = a.case_id
JOIN params p ON p.id = c.params_id JOIN params p ON p.id = c.params_id
`, selected.Column) `, groupExpr, selected.Column)
where := make([]string, 0, 2) where := make([]string, 0, 2)
args := make([]any, 0, 2) args := make([]any, 0, 2)
if filterParam != "" { if filterParam != "" {
@@ -97,7 +118,11 @@ func (c *AnalizeController) Series(ctx *gin.Context) {
if len(where) > 0 { if len(where) > 0 {
query += " WHERE " + strings.Join(where, " AND ") query += " WHERE " + strings.Join(where, " AND ")
} }
query += fmt.Sprintf(" ORDER BY p.%s, a.id", selected.Column) if groupParam != "" {
query += fmt.Sprintf(" ORDER BY ROUND(p.%s, 6), p.%s, a.id", groupSelected.Column, selected.Column)
} else {
query += fmt.Sprintf(" ORDER BY p.%s, a.id", selected.Column)
}
var rows []row var rows []row
if err := c.db.Raw(query, args...).Scan(&rows).Error; err != nil { if err := c.db.Raw(query, args...).Scan(&rows).Error; err != nil {
@@ -106,23 +131,63 @@ func (c *AnalizeController) Series(ctx *gin.Context) {
} }
points := make([]AnalizeSeriesPoint, 0, len(rows)) points := make([]AnalizeSeriesPoint, 0, len(rows))
for _, r := range rows { groups := make([]AnalizeSeriesGroup, 0)
points = append(points, AnalizeSeriesPoint{ if groupParam == "" {
CaseID: r.CaseID, for _, r := range rows {
CaseName: r.CaseName, points = append(points, AnalizeSeriesPoint{
X: r.X, CaseID: r.CaseID,
Omega: r.Omega, CaseName: r.CaseName,
PsiMax: r.PsiMax, X: r.X,
}) Omega: r.Omega,
PsiMax: r.PsiMax,
})
}
} else {
groupIndex := map[string]int{}
for _, r := range rows {
groupValue := round6(r.GroupVal)
key := fmt.Sprintf("%.6f", groupValue)
idx, ok := groupIndex[key]
if !ok {
idx = len(groups)
groupIndex[key] = idx
groups = append(groups, AnalizeSeriesGroup{
GroupValue: groupValue,
GroupLabel: fmt.Sprintf("%s=%.6f", groupSelected.Label, groupValue),
Points: []AnalizeSeriesPoint{},
})
}
groups[idx].Points = append(groups[idx].Points, AnalizeSeriesPoint{
CaseID: r.CaseID,
CaseName: r.CaseName,
X: r.X,
Omega: r.Omega,
PsiMax: r.PsiMax,
})
}
} }
ctx.JSON(http.StatusOK, AnalizeSeriesResponse{ ctx.JSON(http.StatusOK, AnalizeSeriesResponse{
Parameter: param, Parameter: param,
Label: selected.Label, Label: selected.Label,
Points: points, GroupParameter: groupParam,
GroupLabel: groupSelected.Label,
Points: points,
Groups: groups,
}) })
} }
func groupExpr(groupParam, groupColumn string) string {
if groupParam == "" {
return "0"
}
return fmt.Sprintf("ROUND(p.%s, 6)", groupColumn)
}
func round6(v float64) float64 {
return math.Round(v*1e6) / 1e6
}
func (c *AnalizeController) Recalculate(ctx *gin.Context) { func (c *AnalizeController) Recalculate(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil { if err != nil {
@@ -136,7 +201,17 @@ func (c *AnalizeController) Recalculate(ctx *gin.Context) {
return return
} }
csvPath := filepath.Join(".", fmt.Sprintf("%d", item.CaseID), "foo.csv") var controlCase control_case.ControlCase
if err := c.db.First(&controlCase, item.CaseID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx.JSON(http.StatusNotFound, gin.H{"error": "control case not found"})
} else {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
return
}
csvPath := controlCase.FooCSVPath()
updated, err := AnalyzeCSV(c.db, item.CaseID, item.CaseName, csvPath) updated, err := AnalyzeCSV(c.db, item.CaseID, item.CaseName, csvPath)
if err != nil { if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
@@ -145,3 +220,25 @@ func (c *AnalizeController) Recalculate(ctx *gin.Context) {
ctx.JSON(http.StatusOK, updated) ctx.JSON(http.StatusOK, updated)
} }
func (c *AnalizeController) RecalculateControlCaseAnalysis(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
return
}
var controlCase control_case.ControlCase
if err := c.db.First(&controlCase, id).Error; err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "record not found"})
return
}
result, err := AnalyzeCSV(c.db, controlCase.ID, controlCase.Name, controlCase.FooCSVPath())
if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, result)
}
+2
View File
@@ -13,4 +13,6 @@ func RegisterApp(r *gin.Engine, db *gorm.DB) {
go4rest.RegisterCRUDRoutes(r, "analize", controller) go4rest.RegisterCRUDRoutes(r, "analize", controller)
r.GET("/api/analize/series", controller.Series) r.GET("/api/analize/series", controller.Series)
r.POST("/api/analize/:id/recalculate", controller.Recalculate) r.POST("/api/analize/:id/recalculate", controller.Recalculate)
r.POST("/api/control_case/:id/recalculate-analysis", controller.RecalculateControlCaseAnalysis)
go RunAnalyzeWorker(db)
} }
+28 -2
View File
@@ -8,11 +8,37 @@ import (
"os" "os"
"strconv" "strconv"
"strings" "strings"
"time"
"control/control_case"
"gonum.org/v1/gonum/dsp/fourier" "gonum.org/v1/gonum/dsp/fourier"
"gorm.io/gorm" "gorm.io/gorm"
) )
func RunAnalyzeWorker(db *gorm.DB) {
for {
var cases []control_case.ControlCase
if err := db.Where("status = ?", "R").Find(&cases).Error; err != nil {
fmt.Printf("failed to load cases for analysis: %v\n", err)
time.Sleep(10 * time.Second)
continue
}
for _, controlCase := range cases {
if _, err := AnalyzeCSV(db, controlCase.ID, controlCase.Name, controlCase.FooCSVPath()); err != nil {
fmt.Printf("failed to analyze case %d: %v\n", controlCase.ID, err)
continue
}
if err := db.Model(&control_case.ControlCase{}).Where("id = ?", controlCase.ID).Update("status", "D").Error; err != nil {
fmt.Printf("failed to mark case %d as done: %v\n", controlCase.ID, err)
}
}
time.Sleep(10 * time.Second)
}
}
func AnalyzeCSV(db *gorm.DB, caseID uint, caseName, csvPath string) (*Analize, error) { func AnalyzeCSV(db *gorm.DB, caseID uint, caseName, csvPath string) (*Analize, error) {
file, err := os.Open(csvPath) file, err := os.Open(csvPath)
if err != nil { if err != nil {
@@ -136,8 +162,8 @@ func maxFloat(values []float64) float64 {
if len(values) == 0 { if len(values) == 0 {
return 0 return 0
} }
max := values[0] max := values[len(values)-1]
for _, v := range values[1:] { for _, v := range values[len(values):] {
if v > max { if v > max {
max = v max = v
} }
+43 -1
View File
@@ -1,16 +1,30 @@
import { createModelApi, apiClient } from "@/api_client.ts"; import { createModelApi, apiClient } from "@/api_client.ts";
import type { Params, ControlCase, Analize } from "@/models.ts"; import type { Params, ControlCase, Analize, InitialCondition } from "@/models.ts";
export const paramsApi = createModelApi<Params>("params"); export const paramsApi = createModelApi<Params>("params");
export const controlCaseApi = createModelApi<ControlCase>("control_case"); export const controlCaseApi = createModelApi<ControlCase>("control_case");
export const analizeApi = createModelApi<Analize>("analize"); export const analizeApi = createModelApi<Analize>("analize");
export const initialConditionApi = createModelApi<InitialCondition>("initial_condition");
export interface CsvChartResponse { export interface CsvChartResponse {
columns: string[]; columns: string[];
rows: Array<Array<string | number>>; rows: Array<Array<string | number>>;
} }
export interface PsiSpectrumPoint {
frequency: number;
amplitude: number;
}
export interface PsiSpectrumResponse {
time_step: number;
points: {
psi_m: PsiSpectrumPoint[];
psi_l: PsiSpectrumPoint[];
};
}
export interface FieldMapResponse { export interface FieldMapResponse {
requested_t: number; requested_t: number;
stage_t: number; stage_t: number;
@@ -19,6 +33,10 @@ export interface FieldMapResponse {
fields: Record<string, number[][]>; fields: Record<string, number[][]>;
} }
export interface InitialConditionResponse {
initial_condition: InitialCondition;
}
export async function getControlCaseChartData(id: number) { export async function getControlCaseChartData(id: number) {
const response = await apiClient.get<CsvChartResponse>( const response = await apiClient.get<CsvChartResponse>(
`/control_case/${id}/chart-data`, `/control_case/${id}/chart-data`,
@@ -26,6 +44,13 @@ export async function getControlCaseChartData(id: number) {
return response.data; return response.data;
} }
export async function getControlCasePsiSpectrum(id: number) {
const response = await apiClient.get<PsiSpectrumResponse>(
`/control_case/${id}/psi-spectrum`,
);
return response.data;
}
export async function getControlCaseFieldMap(id: number, time: number) { export async function getControlCaseFieldMap(id: number, time: number) {
const response = await apiClient.get<FieldMapResponse>( const response = await apiClient.get<FieldMapResponse>(
`/control_case/${id}/field-map`, `/control_case/${id}/field-map`,
@@ -36,6 +61,13 @@ export async function getControlCaseFieldMap(id: number, time: number) {
return response.data; return response.data;
} }
export async function createControlCaseInitialCondition(id: number) {
const response = await apiClient.post<InitialConditionResponse>(
`/control_case/${id}/initial-condition`,
);
return response.data;
}
export interface LaunchControlCasePayload { export interface LaunchControlCasePayload {
params_id: number; params_id: number;
name?: string; name?: string;
@@ -69,16 +101,26 @@ export interface AnalizeSeriesPoint {
psi_max: number; psi_max: number;
} }
export interface AnalizeSeriesGroup {
group_value: number;
group_label: string;
points: AnalizeSeriesPoint[];
}
export interface AnalizeSeriesResponse { export interface AnalizeSeriesResponse {
parameter: string; parameter: string;
label: string; label: string;
group_parameter?: string;
group_label?: string;
points: AnalizeSeriesPoint[]; points: AnalizeSeriesPoint[];
groups?: AnalizeSeriesGroup[];
} }
export interface AnalizeSeriesFilters { export interface AnalizeSeriesFilters {
filter_parameter?: string; filter_parameter?: string;
filter_min?: number; filter_min?: number;
filter_max?: number; filter_max?: number;
group_parameter?: string;
} }
export async function getAnalizeSeries(parameter: string, filters?: AnalizeSeriesFilters) { export async function getAnalizeSeries(parameter: string, filters?: AnalizeSeriesFilters) {
+4
View File
@@ -74,6 +74,10 @@ export function formatApiError(
error: unknown, error: unknown,
fallback = "Не удалось выполнить запрос.", fallback = "Не удалось выполнить запрос.",
): string { ): string {
if (error instanceof Error && !axios.isAxiosError(error)) {
return error.message || fallback;
}
if (!axios.isAxiosError(error)) return fallback; if (!axios.isAxiosError(error)) return fallback;
const payload = error.response?.data; const payload = error.response?.data;
@@ -0,0 +1,73 @@
<script setup lang="ts">
import type { InitialCondition, Params } from '@/models.ts'
defineProps<{
item: Params
form: Params
initialConditions: InitialCondition[]
saving: boolean
}>()
defineEmits<{
submit: []
}>()
</script>
<template>
<div class="card shadow-sm">
<div class="card-header bg-white d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<div class="text-muted small">Редактирование</div>
<div class="fw-semibold">Params #{{ item.id }}</div>
</div>
<div class="text-muted small">Created: {{ new Date(item.created_at).toLocaleString() }}</div>
</div>
<div class="card-body">
<form class="row g-3" @submit.prevent="$emit('submit')">
<div class="col-md-2">
<label class="form-label">rel</label>
<input v-model="form.rel" type="number" step="any" class="form-control" />
</div>
<div class="col-md-2">
<label class="form-label">rel_c</label>
<input v-model="form.rel_c" type="number" step="any" class="form-control" />
</div>
<div class="col-md-2">
<label class="form-label">le</label>
<input v-model="form.le" type="number" step="any" class="form-control" />
</div>
<div class="col-md-2">
<label class="form-label">sc</label>
<input v-model="form.sc" type="number" step="any" class="form-control" />
</div>
<div class="col-md-2">
<label class="form-label">pe</label>
<input v-model="form.pe" type="number" step="any" class="form-control" />
</div>
<div class="col-md-2">
<label class="form-label">ma</label>
<input v-model="form.ma" type="number" step="any" class="form-control" />
</div>
<div class="col-12">
<label class="form-label">initial_condition</label>
<select v-model="form.initial_condition_id" class="form-select">
<option :value="null">Не задано</option>
<option v-for="condition in initialConditions" :key="condition.ID" :value="condition.ID">
{{ condition.name || `#${condition.ID}` }} {{ condition.file_path }}
</option>
</select>
</div>
<div class="col-12">
<label class="form-label">folder_path</label>
<input v-model="form.folder_path" type="text" class="form-control" />
</div>
<div class="col-12 d-flex justify-content-end gap-2">
<button class="btn btn-primary" type="submit" :disabled="saving">
{{ saving ? 'Сохранение...' : 'Сохранить' }}
</button>
</div>
</form>
</div>
</div>
</template>
@@ -0,0 +1,125 @@
<script setup lang="ts">
import type { InitialCondition } from '@/models.ts'
interface BatchForm {
fixed: {
rel: number
rel_c: number
le: number
sc: number
pe: number
ma: number
initial_condition_id: number | null
time: number
}
variable: string
start: number
end: number
step: number
}
defineProps<{
batchForm: BatchForm
initialConditions: InitialCondition[]
variableOptions: string[]
running: boolean
}>()
defineEmits<{
submit: []
reset: []
}>()
</script>
<template>
<div class="card shadow-sm">
<div class="card-header bg-white fw-semibold">Быстрый расчет</div>
<div class="card-body">
<form @submit.prevent="$emit('submit')">
<div class="table-responsive">
<table class="table table-sm align-middle mb-3">
<tbody>
<tr>
<th scope="row" class="text-nowrap">Переменный параметр</th>
<td>
<select v-model="batchForm.variable" class="form-select">
<option v-for="item in variableOptions" :key="item" :value="item">{{ item }}</option>
</select>
</td>
</tr>
<tr>
<th scope="row" class="text-nowrap">Start</th>
<td>
<input v-model.number="batchForm.start" type="number" step="any" class="form-control" />
</td>
</tr>
<tr>
<th scope="row" class="text-nowrap">End</th>
<td>
<input v-model.number="batchForm.end" type="number" step="any" class="form-control" />
</td>
</tr>
<tr>
<th scope="row" class="text-nowrap">Step</th>
<td>
<input v-model.number="batchForm.step" type="number" step="any" class="form-control" />
</td>
</tr>
<tr>
<th scope="row" colspan="2" class="bg-body-tertiary">Фиксированные параметры</th>
</tr>
<tr>
<th scope="row" class="text-nowrap">rel</th>
<td><input v-model.number="batchForm.fixed.rel" type="number" step="any" class="form-control" /></td>
</tr>
<tr>
<th scope="row" class="text-nowrap">rel_c</th>
<td><input v-model.number="batchForm.fixed.rel_c" type="number" step="any" class="form-control" /></td>
</tr>
<tr>
<th scope="row" class="text-nowrap">le</th>
<td><input v-model.number="batchForm.fixed.le" type="number" step="any" class="form-control" /></td>
</tr>
<tr>
<th scope="row" class="text-nowrap">sc</th>
<td><input v-model.number="batchForm.fixed.sc" type="number" step="any" class="form-control" /></td>
</tr>
<tr>
<th scope="row" class="text-nowrap">pe</th>
<td><input v-model.number="batchForm.fixed.pe" type="number" step="any" class="form-control" /></td>
</tr>
<tr>
<th scope="row" class="text-nowrap">ma</th>
<td><input v-model.number="batchForm.fixed.ma" type="number" step="any" class="form-control" /></td>
</tr>
<tr>
<th scope="row" class="text-nowrap">initial_condition</th>
<td>
<select v-model="batchForm.fixed.initial_condition_id" class="form-select">
<option :value="null">Не задано</option>
<option v-for="condition in initialConditions" :key="condition.ID" :value="condition.ID">
{{ condition.name || `#${condition.ID}` }} {{ condition.file_path }}
</option>
</select>
</td>
</tr>
<tr>
<th scope="row" class="text-nowrap">time</th>
<td><input v-model.number="batchForm.fixed.time" type="number" step="any" class="form-control" /></td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-outline-secondary" type="button" @click="$emit('reset')" :disabled="running">
Сбросить
</button>
<button class="btn btn-primary" type="submit" :disabled="running">
{{ running ? 'Создание...' : 'Создать серию' }}
</button>
</div>
</form>
</div>
</div>
</template>
@@ -0,0 +1,140 @@
<script setup lang="ts">
import type { InitialCondition, Params } from '@/models.ts'
interface CreateForm {
rel: number
rel_c: number
le: number
sc: number
pe: number
ma: number
initial_condition_id: number | null
time: number
}
defineProps<{
items: Params[]
loading: boolean
selectedId: number | null
createForm: CreateForm
initialConditions: InitialCondition[]
saving: boolean
launchingId: number | null
page: number
totalPages: number
totalCount: number
pageStart: number
pageEnd: number
}>()
defineEmits<{
create: []
select: [item: Params]
launch: [item: Params]
goToPage: [page: number]
}>()
</script>
<template>
<div class="card shadow-sm">
<div class="card-body p-0">
<div v-if="loading" class="p-4 text-center text-muted">Загрузка...</div>
<div v-else class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>id</th>
<th>rel</th>
<th>rel_c</th>
<th>le</th>
<th>sc</th>
<th>pe</th>
<th>ma</th>
<th>Initial</th>
<th>time</th>
<th>folder_path</th>
<th class="text-end">Действия</th>
</tr>
</thead>
<tbody>
<tr class="table-success">
<td class="fw-semibold text-success">+</td>
<td>
<input v-model.number="createForm.rel" type="number" step="any" class="form-control form-control-sm" placeholder="Rel" />
</td>
<td>
<input v-model.number="createForm.rel_c" type="number" step="any" class="form-control form-control-sm" placeholder="RelC" />
</td>
<td>
<input v-model.number="createForm.le" type="number" step="any" class="form-control form-control-sm" placeholder="Le" />
</td>
<td>
<input v-model.number="createForm.sc" type="number" step="any" class="form-control form-control-sm" placeholder="Sc" />
</td>
<td>
<input v-model.number="createForm.pe" type="number" step="any" class="form-control form-control-sm" placeholder="Pe" />
</td>
<td>
<input v-model.number="createForm.ma" type="number" step="any" class="form-control form-control-sm" placeholder="Ma" />
</td>
<td>
<select v-model="createForm.initial_condition_id" class="form-select form-select-sm">
<option :value="null"></option>
<option v-for="condition in initialConditions" :key="condition.ID" :value="condition.ID">
{{ condition.name || `#${condition.ID}` }}
</option>
</select>
</td>
<td>
<input v-model.number="createForm.time" type="number" step="any" class="form-control form-control-sm" placeholder="Time" />
</td>
<td class="text-muted small">new</td>
<td class="text-end">
<button class="btn btn-sm btn-success" type="button" @click="$emit('create')" :disabled="saving">
{{ saving ? 'Сохранение...' : 'Добавить' }}
</button>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="11" class="text-center text-muted py-4">Записи не найдены</td>
</tr>
<tr
v-for="item in items"
:key="item.id"
:class="{ 'table-primary': item.id === selectedId }"
>
<td class="fw-semibold">{{ item.id }}</td>
<td>{{ item.rel }}</td>
<td>{{ item.rel_c }}</td>
<td>{{ item.le }}</td>
<td>{{ item.sc }}</td>
<td>{{ item.pe }}</td>
<td>{{ item.ma }}</td>
<td class="text-truncate" style="max-width: 180px">
{{ item.initial_condition?.name || item.initial_condition?.file_path || '—' }}
</td>
<td>{{ item.time }}</td>
<td class="text-truncate" style="max-width: 220px">{{ item.folder_path || '—' }}</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-primary" type="button" @click="$emit('select', item)">Редактировать</button>
<button class="btn btn-success" type="button" @click="$emit('launch', item)" :disabled="launchingId === item.id">
{{ launchingId === item.id ? 'Запуск...' : 'Запустить' }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 p-3 border-top">
<div class="text-muted small">Показано {{ pageStart }}-{{ pageEnd }} из {{ totalCount }}</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Pagination">
<button class="btn btn-outline-secondary" type="button" @click="$emit('goToPage', page - 1)" :disabled="page <= 1">Назад</button>
<button class="btn btn-outline-secondary" type="button" disabled>{{ page }} / {{ totalPages }}</button>
<button class="btn btn-outline-secondary" type="button" @click="$emit('goToPage', page + 1)" :disabled="page >= totalPages">Вперёд</button>
</div>
</div>
</div>
</div>
</template>
+16 -6
View File
@@ -11,23 +11,33 @@ export interface Params {
rel: number; rel: number;
rel_c: number; rel_c: number;
le: number; le: number;
pr: number; sc: number;
pe: number; pe: number;
ma: number; ma: number;
initial_condition: string; initial_condition_id: number | null;
initial_condition: InitialCondition | null;
time: number; time: number;
folder_path: string; folder_path: string;
} }
export interface InitialCondition {
ID: number;
CreatedAt: string;
UpdatedAt: string;
DeletedAt: GormDeletedAt | null;
name: string;
file_path: string;
}
export interface ControlCase { export interface ControlCase {
ID: number; ID: number;
CreatedAt: string; CreatedAt: string;
UpdatedAt: string; UpdatedAt: string;
DeletedAt: GormDeletedAt | null; DeletedAt: GormDeletedAt | null;
Name: string; name: string;
ParamsID: number; paramsID: number;
Params: Params; params: Params;
Status: string; status: string;
} }
export interface Analize { export interface Analize {
+19
View File
@@ -0,0 +1,19 @@
const controlCaseStatuses: Record<string, { label: string; className: string }> = {
N: { label: 'В очереди', className: 'text-bg-secondary' },
R: { label: 'Ожидает анализа', className: 'text-bg-warning' },
D: { label: 'Готово', className: 'text-bg-success' },
}
export function controlCaseStatusMeta(status: string | undefined) {
if (!status) {
return { code: '', label: '—', className: 'text-bg-secondary' }
}
const normalized = status.toUpperCase()
const meta = controlCaseStatuses[normalized]
if (!meta) {
return { code: normalized, label: normalized, className: 'text-bg-secondary' }
}
return { code: normalized, ...meta }
}
+316 -40
View File
@@ -1,9 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts' import * as echarts from 'echarts'
import { controlCaseApi, getAnalizeSeries, getControlCaseChartData, getControlCaseFieldMap } from '@/api.ts' import { controlCaseApi, getAnalizeSeries, getControlCaseChartData, getControlCaseFieldMap, getControlCasePsiSpectrum } from '@/api.ts'
import { formatApiError } from '@/api_client.ts' import { formatApiError } from '@/api_client.ts'
import type { ControlCase } from '@/models.ts' import type { ControlCase } from '@/models.ts'
import { controlCaseStatusMeta } from '@/statuses.ts'
const fieldOptions = [ const fieldOptions = [
{ value: 'psi', label: 'psi' }, { value: 'psi', label: 'psi' },
@@ -16,13 +17,15 @@ const parameterOptions = [
{ value: 'Rel', label: 'Rel' }, { value: 'Rel', label: 'Rel' },
{ value: 'RelC', label: 'RelC' }, { value: 'RelC', label: 'RelC' },
{ value: 'Le', label: 'Le' }, { value: 'Le', label: 'Le' },
{ value: 'Pr', label: 'Pr' }, { value: 'Sc', label: 'Sc' },
{ value: 'Pe', label: 'Pe' }, { value: 'Pe', label: 'Pe' },
{ value: 'Ma', label: 'Ma' }, { value: 'Ma', label: 'Ma' },
{ value: 'Time', label: 'Time' }, { value: 'Time', label: 'Time' },
] as const ] as const
const selectedParameter = ref<(typeof parameterOptions)[number]['value']>('Rel') const selectedParameter = ref<(typeof parameterOptions)[number]['value']>('Rel')
const groupParameter = ref('')
const showTrend = ref(false)
const filterParameter = ref('') const filterParameter = ref('')
const filterMin = ref('') const filterMin = ref('')
const filterMax = ref('') const filterMax = ref('')
@@ -32,11 +35,24 @@ const containerRef = ref<HTMLDivElement | null>(null)
const caseChartRef = ref<HTMLDivElement | null>(null) const caseChartRef = ref<HTMLDivElement | null>(null)
const chartTitle = ref('') const chartTitle = ref('')
const seriesData = ref<{ case_id: number; x: number; omega: number; psi_max: number; case_name: string }[]>([]) const seriesData = ref<{ case_id: number; x: number; omega: number; psi_max: number; case_name: string }[]>([])
const groupedSeriesData = ref<{
group_value: number
group_label: string
points: { case_id: number; case_name: string; x: number; omega: number; psi_max: number }[]
}[]>([])
const selectedCaseId = ref<number | null>(null) const selectedCaseId = ref<number | null>(null)
const selectedCase = ref<ControlCase | null>(null) const selectedCase = ref<ControlCase | null>(null)
const selectedCaseLoading = ref(false) const selectedCaseLoading = ref(false)
const selectedCaseError = ref('') const selectedCaseError = ref('')
const selectedCasePoints = ref<{ t: number; psi_m: number }[]>([]) const selectedCasePoints = ref<{ t: number; psi_m: number }[]>([])
const selectedCasePsiLPoints = ref<{ t: number; psi_l: number }[]>([])
const selectedCaseSpectrum = ref<{
time_step: number
points: {
psi_m: { frequency: number; amplitude: number }[]
psi_l: { frequency: number; amplitude: number }[]
}
} | null>(null)
const selectedFieldMap = ref<{ const selectedFieldMap = ref<{
requested_t: number requested_t: number
stage_t: number stage_t: number
@@ -47,6 +63,7 @@ const selectedFieldMap = ref<{
const selectedFieldMapLoading = ref(false) const selectedFieldMapLoading = ref(false)
const selectedFieldMapError = ref('') const selectedFieldMapError = ref('')
const selectedField = ref<(typeof fieldOptions)[number]['value']>('psi') const selectedField = ref<(typeof fieldOptions)[number]['value']>('psi')
const psiViewMode = ref<'time' | 'fft'>('time')
const fieldMapRef = ref<HTMLDivElement | null>(null) const fieldMapRef = ref<HTMLDivElement | null>(null)
let chart: echarts.ECharts | null = null let chart: echarts.ECharts | null = null
let caseChart: echarts.ECharts | null = null let caseChart: echarts.ECharts | null = null
@@ -56,15 +73,160 @@ const selectedLabel = computed(
() => parameterOptions.find((item) => item.value === selectedParameter.value)?.label ?? selectedParameter.value, () => parameterOptions.find((item) => item.value === selectedParameter.value)?.label ?? selectedParameter.value,
) )
function buildOption() { const groupLabel = computed(
() => parameterOptions.find((item) => item.value === groupParameter.value)?.label ?? groupParameter.value,
)
const groupOptions = computed(() => [
{ value: '', label: 'Без группировки' },
...parameterOptions.filter((item) => item.value !== selectedParameter.value),
])
type AnalizePoint = { case_id: number; case_name: string; x: number; omega: number; psi_max: number }
type ChartPoint = {
value: [number, number]
case_id: number
case_name: string
group_label?: string
x: number
omega: number | null
psi_max: number | null
psi_max2: number | null
}
function psiMaxSquared(point: AnalizePoint) {
return point.psi_max * point.psi_max
}
function chartPoint(point: AnalizePoint, y: number, groupLabel?: string): ChartPoint {
return { return {
title: { text: `Omega / PsiMax vs ${chartTitle.value || selectedLabel.value}` }, value: [point.x, y],
case_id: point.case_id,
case_name: point.case_name,
group_label: groupLabel,
x: point.x,
omega: point.omega,
psi_max: point.psi_max,
psi_max2: psiMaxSquared(point),
}
}
function buildPsiMaxSquaredTrend(points: AnalizePoint[], groupLabel?: string) {
const source = points
.map((point) => ({ ...point, psi_max2: psiMaxSquared(point) }))
.filter((point) => Number.isFinite(point.x) && Number.isFinite(point.psi_max2) && point.psi_max2 > 0.1)
if (source.length < 2) return null
const xMean = source.reduce((sum, point) => sum + point.x, 0) / source.length
const yMean = source.reduce((sum, point) => sum + point.psi_max2, 0) / source.length
const denominator = source.reduce((sum, point) => sum + (point.x - xMean) ** 2, 0)
if (denominator === 0) return null
const slope = source.reduce((sum, point) => sum + (point.x - xMean) * (point.psi_max2 - yMean), 0) / denominator
const intercept = yMean - slope * xMean
const sorted = [...source].sort((a, b) => a.x - b.x)
const first = sorted[0]
const last = sorted[sorted.length - 1]
if (!first || !last) return null
const zeroX = slope === 0 ? first.x : -intercept / slope
const startX = Math.min(first.x, last.x, zeroX)
const endX = Math.max(first.x, last.x, zeroX)
return [startX, endX].map((x) => ({
value: [x, slope * x + intercept],
case_id: 0,
case_name: 'Линейный тренд',
group_label: groupLabel,
x,
omega: null,
psi_max: null,
psi_max2: slope * x + intercept,
}))
}
function buildOption() {
if (groupParameter.value && groupedSeriesData.value.length > 0) {
const titleGroup = groupLabel.value || groupParameter.value
return {
title: { text: `Omega / PsiMax^2 vs ${chartTitle.value || selectedLabel.value} grouped by ${titleGroup}` },
tooltip: {
trigger: 'item',
formatter: (params: any) => {
const p = params.data
const isOmega = params.seriesName.includes('Omega')
const valueLabel = isOmega ? 'Omega' : 'PsiMax^2'
const value = isOmega ? p.omega : p.psi_max2
return [
`<strong>${p.case_name}</strong>`,
`${selectedLabel.value}: ${p.x}`,
`${titleGroup}: ${p.group_label ?? '—'}`,
`${valueLabel}: ${value}`,
].join('<br/>')
},
},
legend: { top: 0, type: 'scroll' },
grid: { left: 56, right: 32, top: 80, bottom: 56, containLabel: true },
dataZoom: [
{ type: 'inside', xAxisIndex: 0 },
{ type: 'inside', yAxisIndex: [0, 1] },
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
],
xAxis: { type: 'value', name: selectedLabel.value },
yAxis: [
{ type: 'value', name: 'Omega', position: 'left' },
{ type: 'value', name: 'PsiMax^2', position: 'right' },
],
series: groupedSeriesData.value.flatMap((group) => {
const trend = showTrend.value ? buildPsiMaxSquaredTrend(group.points, group.group_label) : null
return [
{
name: `${group.group_label} · Omega`,
type: 'line',
smooth: true,
connectNulls: true,
showSymbol: true,
yAxisIndex: 0,
encode: { x: 0, y: 1 },
data: group.points.map((point) => chartPoint(point, point.omega, group.group_label)),
},
{
name: `${group.group_label} · PsiMax^2`,
type: 'line',
smooth: true,
connectNulls: true,
showSymbol: true,
yAxisIndex: 1,
encode: { x: 0, y: 1 },
data: group.points.map((point) => chartPoint(point, psiMaxSquared(point), group.group_label)),
},
...(trend
? [{
name: `${group.group_label} · trend PsiMax^2`,
type: 'line',
showSymbol: false,
yAxisIndex: 1,
encode: { x: 0, y: 1 },
lineStyle: { type: 'dashed', width: 2 },
data: trend,
}]
: []),
]
}),
}
}
const trend = showTrend.value ? buildPsiMaxSquaredTrend(seriesData.value) : null
return {
title: { text: `Omega / PsiMax^2 vs ${chartTitle.value || selectedLabel.value}` },
tooltip: { tooltip: {
trigger: 'item', trigger: 'item',
formatter: (params: any) => { formatter: (params: any) => {
const p = params.data const p = params.data
const valueLabel = params.seriesName === 'Omega' ? 'Omega' : 'PsiMax' const isOmega = params.seriesName === 'Omega'
const value = params.seriesName === 'Omega' ? p.omega : p.psi_max const valueLabel = isOmega ? 'Omega' : 'PsiMax^2'
const value = isOmega ? p.omega : p.psi_max2
return [ return [
`<strong>${p.case_name}</strong>`, `<strong>${p.case_name}</strong>`,
`${selectedLabel.value}: ${p.x}`, `${selectedLabel.value}: ${p.x}`,
@@ -74,10 +236,15 @@ function buildOption() {
}, },
legend: { top: 28 }, legend: { top: 28 },
grid: { left: 56, right: 32, top: 80, bottom: 56, containLabel: true }, grid: { left: 56, right: 32, top: 80, bottom: 56, containLabel: true },
dataZoom: [
{ type: 'inside', xAxisIndex: 0 },
{ type: 'inside', yAxisIndex: [0, 1] },
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
],
xAxis: { type: 'value', name: selectedLabel.value }, xAxis: { type: 'value', name: selectedLabel.value },
yAxis: [ yAxis: [
{ type: 'value', name: 'Omega', position: 'left' }, { type: 'value', name: 'Omega', position: 'left' },
{ type: 'value', name: 'PsiMax', position: 'right' }, { type: 'value', name: 'PsiMax^2', position: 'right' },
], ],
series: [ series: [
{ {
@@ -85,31 +252,28 @@ function buildOption() {
type: 'scatter', type: 'scatter',
symbolSize: 10, symbolSize: 10,
yAxisIndex: 0, yAxisIndex: 0,
data: seriesData.value.map((point) => ({ encode: { x: 0, y: 1 },
case_id: point.case_id, data: seriesData.value.map((point) => chartPoint(point, point.omega)),
name: point.case_name,
value: [point.x, point.omega],
case_name: point.case_name,
x: point.x,
omega: point.omega,
psi_max: point.psi_max,
})),
}, },
{ {
name: 'PsiMax', name: 'PsiMax^2',
type: 'scatter', type: 'scatter',
symbolSize: 10, symbolSize: 10,
yAxisIndex: 1, yAxisIndex: 1,
data: seriesData.value.map((point) => ({ encode: { x: 0, y: 1 },
case_id: point.case_id, data: seriesData.value.map((point) => chartPoint(point, psiMaxSquared(point))),
name: point.case_name,
value: [point.x, point.psi_max],
case_name: point.case_name,
x: point.x,
omega: point.omega,
psi_max: point.psi_max,
})),
}, },
...(trend
? [{
name: 'trend PsiMax^2',
type: 'line',
showSymbol: false,
yAxisIndex: 1,
encode: { x: 0, y: 1 },
lineStyle: { type: 'dashed', width: 2 },
data: trend,
}]
: []),
], ],
} }
} }
@@ -117,22 +281,67 @@ function buildOption() {
function renderChart() { function renderChart() {
if (!chart) return if (!chart) return
chart.setOption(buildOption(), true) chart.setOption(buildOption(), true)
requestAnimationFrame(() => chart?.resize())
} }
function buildCaseOption() { function buildCaseOption() {
if (psiViewMode.value === 'fft') {
return {
title: { text: 'FFT spectrum of psi' },
tooltip: { trigger: 'axis' },
legend: { top: 0 },
grid: { left: 56, right: 24, top: 48, bottom: 56, containLabel: true },
dataZoom: [
{ type: 'inside', xAxisIndex: 0 },
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
],
xAxis: { type: 'value', name: 'f' },
yAxis: { type: 'value', name: 'Amplitude' },
series: [
{
name: 'psi_m',
type: 'line',
smooth: true,
showSymbol: false,
data: selectedCaseSpectrum.value?.points.psi_m.map((point) => [point.frequency, point.amplitude]) ?? [],
},
{
name: 'psi_l',
type: 'line',
smooth: true,
showSymbol: false,
data: selectedCaseSpectrum.value?.points.psi_l.map((point) => [point.frequency, point.amplitude]) ?? [],
},
],
}
}
return { return {
title: { text: 'psi_m / time' }, title: { text: 'psi_m / psi_l / time' },
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { top: 0 },
grid: { left: 56, right: 24, top: 48, bottom: 48, containLabel: true }, grid: { left: 56, right: 24, top: 48, bottom: 48, containLabel: true },
dataZoom: [
{ type: 'inside', xAxisIndex: 0 },
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
],
xAxis: { type: 'value', name: 't' }, xAxis: { type: 'value', name: 't' },
yAxis: { type: 'value', name: 'psi_m' }, yAxis: { type: 'value', name: 'psi' },
series: [ series: [
{ {
name: 'psi_m',
type: 'line', type: 'line',
smooth: true, smooth: true,
showSymbol: false, showSymbol: false,
data: selectedCasePoints.value.map((point) => [point.t, point.psi_m]), data: selectedCasePoints.value.map((point) => [point.t, point.psi_m]),
}, },
{
name: 'psi_l',
type: 'line',
smooth: true,
showSymbol: false,
data: selectedCasePsiLPoints.value.map((point) => [point.t, point.psi_l]),
},
], ],
} }
} }
@@ -181,6 +390,11 @@ function buildFieldMapOption() {
}, },
}, },
grid: { left: 56, right: 32, top: 64, bottom: 48, containLabel: true }, grid: { left: 56, right: 32, top: 64, bottom: 48, containLabel: true },
dataZoom: [
{ type: 'inside', xAxisIndex: 0 },
{ type: 'inside', yAxisIndex: 0 },
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 8 },
],
xAxis: { type: 'category', name: 'x', data: Array.from({ length: cols }, (_, index) => index) }, xAxis: { type: 'category', name: 'x', data: Array.from({ length: cols }, (_, index) => index) },
yAxis: { type: 'category', name: 'y', data: Array.from({ length: rows }, (_, index) => index), inverse: true }, yAxis: { type: 'category', name: 'y', data: Array.from({ length: rows }, (_, index) => index), inverse: true },
visualMap: { visualMap: {
@@ -233,12 +447,25 @@ async function loadData() {
try { try {
const response = await getAnalizeSeries(selectedParameter.value, { const response = await getAnalizeSeries(selectedParameter.value, {
group_parameter: groupParameter.value || undefined,
filter_parameter: filterParameter.value || undefined, filter_parameter: filterParameter.value || undefined,
filter_min: filterMin.value === '' ? undefined : Number(filterMin.value), filter_min: filterMin.value === '' ? undefined : Number(filterMin.value),
filter_max: filterMax.value === '' ? undefined : Number(filterMax.value), filter_max: filterMax.value === '' ? undefined : Number(filterMax.value),
}) })
chartTitle.value = response.label chartTitle.value = response.label
seriesData.value = response.points.map((point) => ({ groupedSeriesData.value = (response.groups ?? []).map((group) => ({
group_value: group.group_value,
group_label: group.group_label,
points: group.points.map((point) => ({
case_id: point.case_id,
case_name: point.case_name,
x: point.x,
omega: point.omega,
psi_max: point.psi_max,
})),
}))
const responsePoints = response.points ?? groupedSeriesData.value.flatMap((group) => group.points)
seriesData.value = responsePoints.map((point) => ({
case_id: point.case_id, case_id: point.case_id,
x: point.x, x: point.x,
omega: point.omega, omega: point.omega,
@@ -249,6 +476,7 @@ async function loadData() {
} catch (err) { } catch (err) {
error.value = formatApiError(err, 'Не удалось загрузить данные графика.') error.value = formatApiError(err, 'Не удалось загрузить данные графика.')
seriesData.value = [] seriesData.value = []
groupedSeriesData.value = []
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -260,24 +488,28 @@ async function loadSelectedCase(caseId: number) {
selectedCaseError.value = '' selectedCaseError.value = ''
selectedCase.value = null selectedCase.value = null
selectedCasePoints.value = [] selectedCasePoints.value = []
selectedCasePsiLPoints.value = []
selectedCaseSpectrum.value = null
selectedFieldMap.value = null selectedFieldMap.value = null
selectedFieldMapError.value = '' selectedFieldMapError.value = ''
selectedFieldMapLoading.value = false selectedFieldMapLoading.value = false
fieldMapChart?.clear() fieldMapChart?.clear()
try { try {
const [caseData, csvData] = await Promise.all([ const [caseData, csvData, spectrumData] = await Promise.all([
controlCaseApi.retrieve(caseId), controlCaseApi.retrieve(caseId),
getControlCaseChartData(caseId), getControlCaseChartData(caseId),
getControlCasePsiSpectrum(caseId),
]) ])
selectedCase.value = caseData selectedCase.value = caseData
const tIndex = csvData.columns.findIndex((column) => column.toLowerCase() === 't') const tIndex = csvData.columns.findIndex((column) => column.toLowerCase() === 't')
const psiMIndex = csvData.columns.findIndex((column) => column.toLowerCase() === 'psi_m') const psiMIndex = csvData.columns.findIndex((column) => column.toLowerCase() === 'psi_m')
const psiLIndex = csvData.columns.findIndex((column) => column.toLowerCase() === 'psi_l')
if (tIndex < 0 || psiMIndex < 0) { if (tIndex < 0 || psiMIndex < 0 || psiLIndex < 0) {
throw new Error('CSV does not contain t/psi_m columns') throw new Error('CSV does not contain t/psi_m/psi_l columns')
} }
selectedCasePoints.value = csvData.rows selectedCasePoints.value = csvData.rows
@@ -287,6 +519,15 @@ async function loadSelectedCase(caseId: number) {
})) }))
.filter((point) => Number.isFinite(point.t) && Number.isFinite(point.psi_m)) .filter((point) => Number.isFinite(point.t) && Number.isFinite(point.psi_m))
selectedCasePsiLPoints.value = csvData.rows
.map((row) => ({
t: Number(row[tIndex]),
psi_l: Number(row[psiLIndex]),
}))
.filter((point) => Number.isFinite(point.t) && Number.isFinite(point.psi_l))
selectedCaseSpectrum.value = spectrumData
renderCaseChart() renderCaseChart()
} catch (err) { } catch (err) {
selectedCaseError.value = formatApiError(err, 'Не удалось загрузить расчетный случай.') selectedCaseError.value = formatApiError(err, 'Не удалось загрузить расчетный случай.')
@@ -353,8 +594,11 @@ onMounted(async () => {
}) })
watch(selectedParameter, loadData) watch(selectedParameter, loadData)
watch(groupParameter, loadData)
watch(showTrend, renderChart)
watch(selectedField, renderFieldMap) watch(selectedField, renderFieldMap)
watch(selectedFieldMap, renderFieldMap) watch(selectedFieldMap, renderFieldMap)
watch(psiViewMode, renderCaseChart)
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize) window.removeEventListener('resize', handleResize)
@@ -370,16 +614,25 @@ onBeforeUnmount(() => {
<template> <template>
<section class="d-flex flex-column gap-3"> <section class="d-flex flex-column gap-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2"> <div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div> <div>
<h1 class="h3 mb-1">Analize graph</h1> <h1 class="h3 mb-1">Analize graph</h1>
<p class="text-muted mb-0">Omega и PsiMax по выбранному параметру</p> <p class="text-muted mb-0">Omega, PsiMax² и линейный тренд по выбранному параметру</p>
</div> </div>
<div class="d-flex gap-2 align-items-center"> <div class="d-flex gap-2 align-items-center">
<select v-model="selectedParameter" class="form-select"> <select v-model="selectedParameter" class="form-select">
<option v-for="option in parameterOptions" :key="option.value" :value="option.value"> <option v-for="option in parameterOptions" :key="option.value" :value="option.value">
{{ option.label }} {{ option.label }}
</option> </option>
</select> </select>
<select v-model="groupParameter" class="form-select">
<option v-for="option in groupOptions" :key="option.value" :value="option.value">
{{ option.label }}
</option>
</select>
<label class="form-check form-switch mb-0 text-nowrap">
<input v-model="showTrend" class="form-check-input" type="checkbox" />
<span class="form-check-label">Тренд</span>
</label>
<button class="btn btn-outline-secondary" type="button" @click="loadData" :disabled="loading"> <button class="btn btn-outline-secondary" type="button" @click="loadData" :disabled="loading">
Обновить Обновить
</button> </button>
@@ -448,15 +701,17 @@ onBeforeUnmount(() => {
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
<div class="col-12 col-lg-4"> <div class="col-12 col-lg-4">
<div class="text-muted small">Название</div> <div class="text-muted small">Название</div>
<div class="fw-semibold">{{ selectedCase.Name }}</div> <div class="fw-semibold">{{ selectedCase.name }}</div>
</div> </div>
<div class="col-12 col-lg-4"> <div class="col-12 col-lg-4">
<div class="text-muted small">Статус</div> <div class="text-muted small">Статус</div>
<div class="fw-semibold">{{ selectedCase.Status }}</div> <span class="badge" :class="controlCaseStatusMeta(selectedCase.status).className">
{{ controlCaseStatusMeta(selectedCase.status).label }}
</span>
</div> </div>
<div class="col-12 col-lg-4"> <div class="col-12 col-lg-4">
<div class="text-muted small">Params ID</div> <div class="text-muted small">Params ID</div>
<div class="fw-semibold">{{ selectedCase.ParamsID }}</div> <div class="fw-semibold">{{ selectedCase.paramsID }}</div>
</div> </div>
</div> </div>
@@ -487,13 +742,34 @@ onBeforeUnmount(() => {
</div> </div>
</template> </template>
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<button
class="btn btn-sm"
:class="psiViewMode === 'time' ? 'btn-primary' : 'btn-outline-primary'"
type="button"
@click="psiViewMode = 'time'"
:disabled="!selectedCase"
>
Time
</button>
<button
class="btn btn-sm"
:class="psiViewMode === 'fft' ? 'btn-primary' : 'btn-outline-primary'"
type="button"
@click="psiViewMode = 'fft'"
:disabled="!selectedCaseSpectrum"
>
FFT
</button>
</div>
<div class="position-relative" style="min-height: 360px;"> <div class="position-relative" style="min-height: 360px;">
<div ref="caseChartRef" style="height: 360px; width: 100%;"></div> <div ref="caseChartRef" style="height: 360px; width: 100%;"></div>
<div <div
v-if="!selectedCase && !selectedCaseLoading && !selectedCaseError" v-if="!selectedCase && !selectedCaseLoading && !selectedCaseError"
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center text-muted" class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center text-muted"
> >
Нажмите на точку на верхнем графике, чтобы увидеть расчетный случай. Нажмите на точку на верхнем графике, чтобы увидеть `psi`.
</div> </div>
</div> </div>
+49 -5
View File
@@ -1,14 +1,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router' import { RouterLink } from 'vue-router'
import { analizeApi, recalculateAnalize } from '@/api.ts' import { analizeApi, recalculateAnalize } from '@/api.ts'
import { formatApiError } from '@/api_client.ts' import { formatApiError, isPaginatedResponse } from '@/api_client.ts'
import type { Analize } from '@/models.ts' import type { Analize } from '@/models.ts'
const items = ref<Analize[]>([]) const items = ref<Analize[]>([])
const loading = ref(false) const loading = ref(false)
const error = ref('') const error = ref('')
const recalculatingId = ref<number | null>(null) const recalculatingId = ref<number | null>(null)
const page = ref(1)
const totalCount = ref(0)
const serverPageSize = ref(0)
const total = computed(() => totalCount.value)
const totalPages = computed(() => {
const size = serverPageSize.value || items.value.length || 1
return Math.max(1, Math.ceil(totalCount.value / size))
})
const pageStart = computed(() => {
const size = serverPageSize.value || items.value.length
return totalCount.value === 0 || size === 0 ? 0 : (page.value - 1) * size + 1
})
const pageEnd = computed(() => {
const size = serverPageSize.value || items.value.length
return size === 0 ? 0 : Math.min(page.value * size, totalCount.value)
})
function normalizeAnalize(item: Analize | Record<string, unknown>): Analize { function normalizeAnalize(item: Analize | Record<string, unknown>): Analize {
const record = item as Record<string, unknown> const record = item as Record<string, unknown>
@@ -32,13 +49,30 @@ function formatDate(value: string | undefined) {
return Number.isNaN(date.getTime()) ? value : date.toLocaleString() return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
} }
async function goToPage(nextPage: number) {
page.value = Math.min(Math.max(1, nextPage), totalPages.value)
await loadItems()
}
async function loadItems() { async function loadItems() {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
const response = await analizeApi.listAll() const response = await analizeApi.list({ page: page.value })
items.value = response.map((item) => normalizeAnalize(item)) if (isPaginatedResponse(response)) {
items.value = response.results.map((item) => normalizeAnalize(item))
totalCount.value = response.count
if (serverPageSize.value === 0 && response.results.length > 0) {
serverPageSize.value = response.results.length
}
} else {
items.value = response.map((item) => normalizeAnalize(item))
totalCount.value = response.length
if (serverPageSize.value === 0 && response.length > 0) {
serverPageSize.value = response.length
}
}
} catch (err) { } catch (err) {
error.value = formatApiError(err, 'Не удалось загрузить analize.') error.value = formatApiError(err, 'Не удалось загрузить analize.')
} finally { } finally {
@@ -69,7 +103,7 @@ onMounted(loadItems)
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2"> <div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
<div> <div>
<h1 class="h3 mb-1">Analize</h1> <h1 class="h3 mb-1">Analize</h1>
<p class="text-muted mb-0">Результаты обработки расчетных случаев</p> <p class="text-muted mb-0">Результаты обработки расчетных случаев. Всего записей: {{ total }}</p>
</div> </div>
<button class="btn btn-outline-secondary" type="button" @click="loadItems" :disabled="loading"> <button class="btn btn-outline-secondary" type="button" @click="loadItems" :disabled="loading">
Обновить Обновить
@@ -132,6 +166,16 @@ onMounted(loadItems)
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 p-3 border-top">
<div class="text-muted small">
Показано {{ pageStart }}-{{ pageEnd }} из {{ totalCount }}
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Pagination">
<button class="btn btn-outline-secondary" type="button" @click="goToPage(page - 1)" :disabled="page <= 1">Назад</button>
<button class="btn btn-outline-secondary" type="button" disabled>{{ page }} / {{ totalPages }}</button>
<button class="btn btn-outline-secondary" type="button" @click="goToPage(page + 1)" :disabled="page >= totalPages">Вперёд</button>
</div>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -31,6 +31,10 @@ function buildOption(payload: CsvChartResponse): echarts.EChartsOption {
tooltip: { trigger: 'axis' }, tooltip: { trigger: 'axis' },
legend: { top: 0 }, legend: { top: 0 },
grid: { left: 48, right: 24, top: 48, bottom: 48, containLabel: true }, grid: { left: 48, right: 24, top: 48, bottom: 48, containLabel: true },
dataZoom: [
{ type: 'inside', xAxisIndex: 0 },
{ type: 'slider', xAxisIndex: 0, height: 18, bottom: 10 },
],
xAxis: { xAxis: {
type: 'category', type: 'category',
name: xKey ?? 'x', name: xKey ?? 'x',
+35 -5
View File
@@ -1,14 +1,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router' import { RouterLink, useRoute } from 'vue-router'
import { controlCaseApi, recalculateControlCaseAnalysis } from '@/api.ts' import { controlCaseApi, recalculateControlCaseAnalysis, createControlCaseInitialCondition } from '@/api.ts'
import { formatApiError } from '@/api_client.ts' import { formatApiError } from '@/api_client.ts'
import type { ControlCase } from '@/models.ts' import type { ControlCase } from '@/models.ts'
import { controlCaseStatusMeta } from '@/statuses.ts'
const route = useRoute() const route = useRoute()
const item = ref<ControlCase | null>(null) const item = ref<ControlCase | null>(null)
const loading = ref(false) const loading = ref(false)
const recalculating = ref(false) const recalculating = ref(false)
const creatingInitialCondition = ref(false)
const error = ref('') const error = ref('')
const statusMessage = ref('') const statusMessage = ref('')
@@ -73,6 +75,23 @@ async function recalculateAnalysis() {
} }
} }
async function createInitialCondition() {
if (!Number.isFinite(id.value)) return
creatingInitialCondition.value = true
error.value = ''
statusMessage.value = ''
try {
const response = await createControlCaseInitialCondition(id.value)
statusMessage.value = `Начальные условия созданы: ${response.initial_condition.file_path}`
} catch (err) {
error.value = formatApiError(err, 'Не удалось создать начальные условия.')
} finally {
creatingInitialCondition.value = false
}
}
onMounted(loadItem) onMounted(loadItem)
watch(id, loadItem) watch(id, loadItem)
</script> </script>
@@ -102,6 +121,15 @@ watch(id, loadItem)
> >
{{ recalculating ? 'Пересчет...' : 'Пересчитать анализ' }} {{ recalculating ? 'Пересчет...' : 'Пересчитать анализ' }}
</button> </button>
<button
v-if="item"
class="btn btn-outline-secondary"
type="button"
@click="createInitialCondition"
:disabled="creatingInitialCondition"
>
{{ creatingInitialCondition ? 'Создание...' : 'Взять за начальные условия' }}
</button>
</div> </div>
<div v-if="error" class="alert alert-danger mb-0" role="alert">{{ error }}</div> <div v-if="error" class="alert alert-danger mb-0" role="alert">{{ error }}</div>
@@ -116,9 +144,11 @@ watch(id, loadItem)
<div class="card-header bg-white d-flex justify-content-between align-items-center flex-wrap gap-2"> <div class="card-header bg-white d-flex justify-content-between align-items-center flex-wrap gap-2">
<div> <div>
<div class="text-muted small">Название</div> <div class="text-muted small">Название</div>
<div class="fw-semibold">{{ item.Name || '—' }}</div> <div class="fw-semibold">{{ item.name || '—' }}</div>
</div> </div>
<span class="badge text-bg-secondary fs-6">{{ item.Status || '—' }}</span> <span class="badge fs-6" :class="controlCaseStatusMeta(item.status).className">
{{ controlCaseStatusMeta(item.status).label }}
</span>
</div> </div>
<div class="card-body"> <div class="card-body">
<dl class="row mb-0"> <dl class="row mb-0">
@@ -126,7 +156,7 @@ watch(id, loadItem)
<dd class="col-sm-9">{{ item.ID }}</dd> <dd class="col-sm-9">{{ item.ID }}</dd>
<dt class="col-sm-3">Params ID</dt> <dt class="col-sm-3">Params ID</dt>
<dd class="col-sm-9">{{ item.ParamsID }}</dd> <dd class="col-sm-9">{{ item.paramsID }}</dd>
<dt class="col-sm-3">Создан</dt> <dt class="col-sm-3">Создан</dt>
<dd class="col-sm-9">{{ formatDate(item.CreatedAt) }}</dd> <dd class="col-sm-9">{{ formatDate(item.CreatedAt) }}</dd>
@@ -146,7 +176,7 @@ watch(id, loadItem)
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-sm mb-0 align-middle"> <table class="table table-sm mb-0 align-middle">
<tbody> <tbody>
<tr v-for="(value, key) in item.Params" :key="key"> <tr v-for="(value, key) in item.params" :key="key">
<th class="table-light" style="width: 240px">{{ key }}</th> <th class="table-light" style="width: 240px">{{ key }}</th>
<td>{{ formatValue(value) }}</td> <td>{{ formatValue(value) }}</td>
</tr> </tr>
+98 -51
View File
@@ -1,85 +1,95 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from "vue";
import { RouterLink } from 'vue-router' import { RouterLink } from "vue-router";
import { controlCaseApi } from '@/api.ts' import { controlCaseApi } from "@/api.ts";
import { formatApiError, isPaginatedResponse } from '@/api_client.ts' import { formatApiError, isPaginatedResponse } from "@/api_client.ts";
import type { ControlCase } from '@/models.ts' import type { ControlCase } from "@/models.ts";
import { controlCaseStatusMeta } from "@/statuses.ts";
const items = ref<ControlCase[]>([]) const items = ref<ControlCase[]>([]);
const loading = ref(false) const loading = ref(false);
const error = ref('') const error = ref("");
const page = ref(1) const page = ref(1);
const totalCount = ref(0) const totalCount = ref(0);
const serverPageSize = ref(0) const serverPageSize = ref(0);
const total = computed(() => totalCount.value) const total = computed(() => totalCount.value);
const totalPages = computed(() => { const totalPages = computed(() => {
const size = serverPageSize.value || items.value.length || 1 const size = serverPageSize.value || items.value.length || 1;
return Math.max(1, Math.ceil(totalCount.value / size)) return Math.max(1, Math.ceil(totalCount.value / size));
}) });
const pageStart = computed(() => { const pageStart = computed(() => {
const size = serverPageSize.value || items.value.length const size = serverPageSize.value || items.value.length;
return totalCount.value === 0 || size === 0 ? 0 : (page.value - 1) * size + 1 return totalCount.value === 0 || size === 0 ? 0 : (page.value - 1) * size + 1;
}) });
const pageEnd = computed(() => { const pageEnd = computed(() => {
const size = serverPageSize.value || items.value.length const size = serverPageSize.value || items.value.length;
return size === 0 ? 0 : Math.min(page.value * size, totalCount.value) return size === 0 ? 0 : Math.min(page.value * size, totalCount.value);
}) });
async function goToPage(nextPage: number) { async function goToPage(nextPage: number) {
page.value = Math.min(Math.max(1, nextPage), totalPages.value) page.value = Math.min(Math.max(1, nextPage), totalPages.value);
await loadItems() await loadItems();
} }
function formatDate(value: string | undefined) { function formatDate(value: string | undefined) {
if (!value) return '—' if (!value) return "—";
const date = new Date(value) const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString() return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
} }
async function loadItems() { async function loadItems() {
loading.value = true loading.value = true;
error.value = '' error.value = "";
try { try {
const response = await controlCaseApi.list({ page: page.value }) const response = await controlCaseApi.list({ page: page.value });
if (isPaginatedResponse(response)) { if (isPaginatedResponse(response)) {
items.value = response.results items.value = response.results;
totalCount.value = response.count totalCount.value = response.count;
if (serverPageSize.value === 0 && response.results.length > 0) { if (serverPageSize.value === 0 && response.results.length > 0) {
serverPageSize.value = response.results.length serverPageSize.value = response.results.length;
} }
} else { } else {
items.value = response items.value = response;
totalCount.value = response.length totalCount.value = response.length;
if (serverPageSize.value === 0 && response.length > 0) { if (serverPageSize.value === 0 && response.length > 0) {
serverPageSize.value = response.length serverPageSize.value = response.length;
} }
} }
} catch (err) { } catch (err) {
error.value = formatApiError(err, 'Не удалось загрузить control_case.') error.value = formatApiError(err, "Не удалось загрузить control_case.");
} finally { } finally {
loading.value = false loading.value = false;
} }
} }
onMounted(loadItems) onMounted(loadItems);
</script> </script>
<template> <template>
<section class="d-flex flex-column gap-3"> <section class="d-flex flex-column gap-3">
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2"> <div
class="d-flex flex-wrap justify-content-between align-items-center gap-2"
>
<div> <div>
<h1 class="h3 mb-1">Control cases</h1> <h1 class="h3 mb-1">Control cases</h1>
<p class="text-muted mb-0">Всего записей: {{ total }}</p> <p class="text-muted mb-0">Всего записей: {{ total }}</p>
</div> </div>
<button class="btn btn-outline-secondary" type="button" @click="loadItems" :disabled="loading"> <button
class="btn btn-outline-secondary"
type="button"
@click="loadItems"
:disabled="loading"
>
Обновить Обновить
</button> </button>
</div> </div>
<div v-if="error" class="alert alert-danger mb-0" role="alert">{{ error }}</div> <div v-if="error" class="alert alert-danger mb-0" role="alert">
{{ error }}
</div>
<div class="card shadow-sm"> <div class="card shadow-sm">
<div class="card-body p-0"> <div class="card-body p-0">
@@ -98,16 +108,31 @@ onMounted(loadItems)
</thead> </thead>
<tbody> <tbody>
<tr v-if="items.length === 0"> <tr v-if="items.length === 0">
<td colspan="6" class="text-center text-muted py-4">Записи не найдены</td> <td colspan="6" class="text-center text-muted py-4">
Записи не найдены
</td>
</tr> </tr>
<tr v-for="item in items" :key="item.ID"> <tr v-for="item in items" :key="item.ID">
<td class="fw-semibold">{{ item.ID }}</td> <td class="fw-semibold">{{ item.ID }}</td>
<td>{{ item.Name || '—' }}</td> <td>{{ item.name || "—" }}</td>
<td><span class="badge text-bg-secondary">{{ item.Status || '—' }}</span></td> <td>
<td>{{ item.ParamsID }}</td> <span
class="badge"
:class="controlCaseStatusMeta(item.status).className"
>
{{ controlCaseStatusMeta(item.status).label }}
</span>
</td>
<td>{{ item.paramsID }}</td>
<td>{{ formatDate(item.CreatedAt) }}</td> <td>{{ formatDate(item.CreatedAt) }}</td>
<td class="text-end"> <td class="text-end">
<RouterLink class="btn btn-sm btn-primary" :to="{ name: 'control-case-detail', params: { id: item.ID } }"> <RouterLink
class="btn btn-sm btn-primary"
:to="{
name: 'control-case-detail',
params: { id: item.ID },
}"
>
Открыть Открыть
</RouterLink> </RouterLink>
</td> </td>
@@ -115,14 +140,36 @@ onMounted(loadItems)
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 p-3 border-top"> <div
class="d-flex flex-wrap justify-content-between align-items-center gap-2 p-3 border-top"
>
<div class="text-muted small"> <div class="text-muted small">
Показано {{ pageStart }}-{{ pageEnd }} из {{ totalCount }} Показано {{ pageStart }}-{{ pageEnd }} из {{ totalCount }}
</div> </div>
<div class="btn-group btn-group-sm" role="group" aria-label="Pagination"> <div
<button class="btn btn-outline-secondary" type="button" @click="goToPage(page - 1)" :disabled="page <= 1">Назад</button> class="btn-group btn-group-sm"
<button class="btn btn-outline-secondary" type="button" disabled>{{ page }} / {{ totalPages }}</button> role="group"
<button class="btn btn-outline-secondary" type="button" @click="goToPage(page + 1)" :disabled="page >= totalPages">Вперёд</button> aria-label="Pagination"
>
<button
class="btn btn-outline-secondary"
type="button"
@click="goToPage(page - 1)"
:disabled="page <= 1"
>
Назад
</button>
<button class="btn btn-outline-secondary" type="button" disabled>
{{ page }} / {{ totalPages }}
</button>
<button
class="btn btn-outline-secondary"
type="button"
@click="goToPage(page + 1)"
:disabled="page >= totalPages"
>
Вперёд
</button>
</div> </div>
</div> </div>
</div> </div>
+124 -369
View File
@@ -1,13 +1,17 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { paramsApi } from "@/api.ts"; import { initialConditionApi, launchControlCase, paramsApi } from "@/api.ts";
import { formatApiError, isPaginatedResponse } from "@/api_client.ts"; import { formatApiError, isPaginatedResponse } from "@/api_client.ts";
import { launchControlCase } from "@/api.ts"; import ParamsEditCard from "@/components/params/ParamsEditCard.vue";
import type { Params } from "@/models.ts"; import ParamsQuickBatchCard from "@/components/params/ParamsQuickBatchCard.vue";
import ParamsTableCard from "@/components/params/ParamsTableCard.vue";
import type { InitialCondition, Params } from "@/models.ts";
const router = useRouter(); const router = useRouter();
const items = ref<Params[]>([]); const items = ref<Params[]>([]);
const initialConditions = ref<InitialCondition[]>([]);
const loading = ref(false); const loading = ref(false);
const saving = ref(false); const saving = ref(false);
const launchingId = ref<number | null>(null); const launchingId = ref<number | null>(null);
@@ -16,6 +20,9 @@ const selectedId = ref<number | null>(null);
const page = ref(1); const page = ref(1);
const totalCount = ref(0); const totalCount = ref(0);
const serverPageSize = ref(0); const serverPageSize = ref(0);
const batchRunning = ref(false);
const batchMessage = ref("");
const formsPrefilled = ref(false);
const form = reactive<Params>({ const form = reactive<Params>({
id: 0, id: 0,
@@ -25,10 +32,11 @@ const form = reactive<Params>({
rel: 0, rel: 0,
rel_c: 0, rel_c: 0,
le: 0, le: 0,
pr: 0, sc: 0,
pe: 0, pe: 0,
ma: 0, ma: 0,
initial_condition: "", initial_condition_id: null,
initial_condition: null,
time: 0, time: 0,
folder_path: "", folder_path: "",
}); });
@@ -37,10 +45,10 @@ const createForm = reactive({
rel: 0, rel: 0,
rel_c: 0, rel_c: 0,
le: 0, le: 0,
pr: 0, sc: 0,
pe: 0, pe: 0,
ma: 0, ma: 0,
initial_condition: "", initial_condition_id: null as number | null,
time: 0, time: 0,
}); });
@@ -49,10 +57,10 @@ const batchForm = reactive({
rel: 0, rel: 0,
rel_c: 0, rel_c: 0,
le: 0, le: 0,
pr: 0, sc: 0,
pe: 0, pe: 0,
ma: 0, ma: 0,
initial_condition: "", initial_condition_id: null as number | null,
time: 0, time: 0,
}, },
variable: "rel", variable: "rel",
@@ -61,8 +69,7 @@ const batchForm = reactive({
step: 1, step: 1,
}); });
const batchRunning = ref(false); const variableOptions = ["rel", "rel_c", "le", "sc", "pe", "ma", "time"];
const batchMessage = ref("");
const selectedItem = computed( const selectedItem = computed(
() => items.value.find((item) => item.id === selectedId.value) ?? null, () => items.value.find((item) => item.id === selectedId.value) ?? null,
@@ -83,25 +90,38 @@ const pageEnd = computed(() => {
return size === 0 ? 0 : Math.min(page.value * size, totalCount.value); return size === 0 ? 0 : Math.min(page.value * size, totalCount.value);
}); });
const variableOptions = ["rel", "rel_c", "le", "pr", "pe", "ma", "time"];
function toNumber(value: string) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function formatDate(value: string | undefined) {
if (!value) return "—";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
}
function selectItem(item: Params) { function selectItem(item: Params) {
selectedId.value = item.id; selectedId.value = item.id;
Object.assign(form, structuredClone(item)); Object.assign(form, structuredClone(item));
} }
function fillCreateFormFromParams(item: Params) {
createForm.rel = item.rel;
createForm.rel_c = item.rel_c;
createForm.le = item.le;
createForm.sc = item.sc;
createForm.pe = item.pe;
createForm.ma = item.ma;
createForm.initial_condition_id = item.initial_condition_id;
createForm.time = item.time;
}
function fillBatchFormFromParams(item: Params) {
batchForm.fixed.rel = item.rel;
batchForm.fixed.rel_c = item.rel_c;
batchForm.fixed.le = item.le;
batchForm.fixed.sc = item.sc;
batchForm.fixed.pe = item.pe;
batchForm.fixed.ma = item.ma;
batchForm.fixed.initial_condition_id = item.initial_condition_id;
batchForm.fixed.time = item.time;
}
function fillFormsFromParams(item: Params) {
fillCreateFormFromParams(item);
fillBatchFormFromParams(item);
}
async function goToPage(nextPage: number) { async function goToPage(nextPage: number) {
page.value = Math.min(Math.max(1, nextPage), totalPages.value); page.value = Math.min(Math.max(1, nextPage), totalPages.value);
await loadItems(); await loadItems();
@@ -111,10 +131,10 @@ function resetCreateForm() {
createForm.rel = 0; createForm.rel = 0;
createForm.rel_c = 0; createForm.rel_c = 0;
createForm.le = 0; createForm.le = 0;
createForm.pr = 0; createForm.sc = 0;
createForm.pe = 0; createForm.pe = 0;
createForm.ma = 0; createForm.ma = 0;
createForm.initial_condition = ""; createForm.initial_condition_id = null;
createForm.time = 0; createForm.time = 0;
} }
@@ -122,10 +142,10 @@ function resetBatchForm() {
batchForm.fixed.rel = 0; batchForm.fixed.rel = 0;
batchForm.fixed.rel_c = 0; batchForm.fixed.rel_c = 0;
batchForm.fixed.le = 0; batchForm.fixed.le = 0;
batchForm.fixed.pr = 0; batchForm.fixed.sc = 0;
batchForm.fixed.pe = 0; batchForm.fixed.pe = 0;
batchForm.fixed.ma = 0; batchForm.fixed.ma = 0;
batchForm.fixed.initial_condition = ""; batchForm.fixed.initial_condition_id = null;
batchForm.fixed.time = 0; batchForm.fixed.time = 0;
batchForm.variable = "rel"; batchForm.variable = "rel";
batchForm.start = 0; batchForm.start = 0;
@@ -170,10 +190,10 @@ async function loadItems() {
} }
} }
if (!selectedId.value) { if (!selectedId.value) {
const first = items.value[0]; const first = items.value[0];
if (first) selectItem(first); if (first) selectItem(first);
} }
} catch (err) { } catch (err) {
error.value = formatApiError(err, "Не удалось загрузить params."); error.value = formatApiError(err, "Не удалось загрузить params.");
} finally { } finally {
@@ -181,6 +201,29 @@ async function loadItems() {
} }
} }
async function loadInitialConditions() {
try {
initialConditions.value = await initialConditionApi.listAll();
} catch (err) {
error.value = formatApiError(err, "Не удалось загрузить начальные условия.");
}
}
async function prefillFormsFromLatestParams() {
if (formsPrefilled.value) return;
try {
const allParams = await paramsApi.listAll();
if (allParams.length === 0) return;
const latest = allParams.reduce((current, item) => (item.id > current.id ? item : current));
fillFormsFromParams(latest);
formsPrefilled.value = true;
} catch {
// Leave default values if the bootstrap fetch fails.
}
}
async function saveItem() { async function saveItem() {
if (!selectedId.value) return; if (!selectedId.value) return;
@@ -189,13 +232,15 @@ async function saveItem() {
try { try {
const updated = await paramsApi.update(selectedId.value, { const updated = await paramsApi.update(selectedId.value, {
...form,
rel: Number(form.rel), rel: Number(form.rel),
rel_c: Number(form.rel_c), rel_c: Number(form.rel_c),
le: Number(form.le), le: Number(form.le),
pr: Number(form.pr), sc: Number(form.sc),
pe: Number(form.pe), pe: Number(form.pe),
ma: Number(form.ma),
initial_condition_id: form.initial_condition_id,
time: Number(form.time), time: Number(form.time),
folder_path: form.folder_path,
}); });
await loadItems(); await loadItems();
@@ -216,10 +261,10 @@ async function createItem() {
rel: Number(createForm.rel), rel: Number(createForm.rel),
rel_c: Number(createForm.rel_c), rel_c: Number(createForm.rel_c),
le: Number(createForm.le), le: Number(createForm.le),
pr: Number(createForm.pr), sc: Number(createForm.sc),
pe: Number(createForm.pe), pe: Number(createForm.pe),
ma: Number(createForm.ma), ma: Number(createForm.ma),
initial_condition: createForm.initial_condition, initial_condition_id: createForm.initial_condition_id,
time: Number(createForm.time), time: Number(createForm.time),
}); });
@@ -249,12 +294,12 @@ async function createBatch() {
rel: Number(batchForm.fixed.rel), rel: Number(batchForm.fixed.rel),
rel_c: Number(batchForm.fixed.rel_c), rel_c: Number(batchForm.fixed.rel_c),
le: Number(batchForm.fixed.le), le: Number(batchForm.fixed.le),
pr: Number(batchForm.fixed.pr), sc: Number(batchForm.fixed.sc),
pe: Number(batchForm.fixed.pe), pe: Number(batchForm.fixed.pe),
ma: Number(batchForm.fixed.ma), ma: Number(batchForm.fixed.ma),
initial_condition: batchForm.fixed.initial_condition, initial_condition_id: batchForm.fixed.initial_condition_id,
time: Number(batchForm.fixed.time), time: Number(batchForm.fixed.time),
} as Record<string, string | number>; } as Record<string, string | number | null>;
payload[batchForm.variable] = value; payload[batchForm.variable] = value;
@@ -291,24 +336,19 @@ async function launchParams(item: Params) {
} }
} }
onMounted(loadItems); onMounted(async () => {
await Promise.all([loadItems(), loadInitialConditions(), prefillFormsFromLatestParams()]);
});
</script> </script>
<template> <template>
<section class="d-flex flex-column gap-3"> <section class="d-flex flex-column gap-3">
<div <div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
class="d-flex flex-wrap justify-content-between align-items-center gap-2"
>
<div> <div>
<h1 class="h3 mb-1">Params</h1> <h1 class="h3 mb-1">Params</h1>
<p class="text-muted mb-0">Таблица параметров с редактированием</p> <p class="text-muted mb-0">Таблица параметров с редактированием</p>
</div> </div>
<button <button class="btn btn-outline-secondary" type="button" @click="loadItems" :disabled="loading">
class="btn btn-outline-secondary"
type="button"
@click="loadItems"
:disabled="loading"
>
Обновить Обновить
</button> </button>
</div> </div>
@@ -321,326 +361,41 @@ onMounted(loadItems);
{{ batchMessage }} {{ batchMessage }}
</div> </div>
<div class="card shadow-sm"> <ParamsQuickBatchCard
<div class="card-header bg-white fw-semibold">Быстрый расчет</div> :batch-form="batchForm"
<div class="card-body"> :initial-conditions="initialConditions"
<form class="row g-3" @submit.prevent="createBatch"> :variable-options="variableOptions"
<div class="col-12 col-lg-4"> :running="batchRunning"
<label class="form-label">Переменный параметр</label> @submit="createBatch"
<select v-model="batchForm.variable" class="form-select"> @reset="resetBatchForm"
<option v-for="item in variableOptions" :key="item" :value="item">{{ item }}</option> />
</select>
</div>
<div class="col-4 col-lg-2">
<label class="form-label">Start</label>
<input v-model.number="batchForm.start" type="number" step="any" class="form-control" />
</div>
<div class="col-4 col-lg-2">
<label class="form-label">End</label>
<input v-model.number="batchForm.end" type="number" step="any" class="form-control" />
</div>
<div class="col-4 col-lg-2">
<label class="form-label">Step</label>
<input v-model.number="batchForm.step" type="number" step="any" class="form-control" />
</div>
<div class="col-12"> <ParamsTableCard
<div class="text-muted small mb-2">Фиксированные параметры</div> :items="items"
</div> :loading="loading"
<div class="col-6 col-lg-2"> :selected-id="selectedId"
<label class="form-label">rel</label> :create-form="createForm"
<input v-model.number="batchForm.fixed.rel" type="number" step="any" class="form-control" /> :initial-conditions="initialConditions"
</div> :saving="saving"
<div class="col-6 col-lg-2"> :launching-id="launchingId"
<label class="form-label">rel_c</label> :page="page"
<input v-model.number="batchForm.fixed.rel_c" type="number" step="any" class="form-control" /> :total-pages="totalPages"
</div> :total-count="totalCount"
<div class="col-6 col-lg-2"> :page-start="pageStart"
<label class="form-label">le</label> :page-end="pageEnd"
<input v-model.number="batchForm.fixed.le" type="number" step="any" class="form-control" /> @create="createItem"
</div> @select="selectItem"
<div class="col-6 col-lg-2"> @launch="launchParams"
<label class="form-label">pr</label> @go-to-page="goToPage"
<input v-model.number="batchForm.fixed.pr" type="number" step="any" class="form-control" /> />
</div>
<div class="col-6 col-lg-2">
<label class="form-label">pe</label>
<input v-model.number="batchForm.fixed.pe" type="number" step="any" class="form-control" />
</div>
<div class="col-6 col-lg-2">
<label class="form-label">ma</label>
<input v-model.number="batchForm.fixed.ma" type="number" step="any" class="form-control" />
</div>
<div class="col-12 col-lg-4">
<label class="form-label">initial_condition</label>
<input v-model="batchForm.fixed.initial_condition" type="text" class="form-control" />
</div>
<div class="col-12 col-lg-4">
<label class="form-label">time</label>
<input v-model.number="batchForm.fixed.time" type="number" step="any" class="form-control" />
</div>
<div class="col-12 d-flex justify-content-end gap-2"> <ParamsEditCard
<button class="btn btn-outline-secondary" type="button" @click="resetBatchForm" :disabled="batchRunning"> v-if="selectedItem"
Сбросить :item="selectedItem"
</button> :form="form"
<button class="btn btn-primary" type="submit" :disabled="batchRunning"> :initial-conditions="initialConditions"
{{ batchRunning ? "Создание..." : "Создать серию" }} :saving="saving"
</button> @submit="saveItem"
</div> />
</form>
</div>
</div>
<div class="card shadow-sm">
<div class="card-body p-0">
<div v-if="loading" class="p-4 text-center text-muted">Загрузка...</div>
<div v-else class="table-responsive">
<table class="table table-sm table-hover align-middle mb-0">
<thead class="table-light">
<tr>
<th>id</th>
<th>rel</th>
<th>rel_c</th>
<th>le</th>
<th>pr</th>
<th>pe</th>
<th>ma</th>
<th>Initial</th>
<th>time</th>
<th>folder_path</th>
<th class="text-end">Действия</th>
</tr>
</thead>
<tbody>
<tr class="table-success">
<td class="fw-semibold text-success">+</td>
<td>
<input
v-model.number="createForm.rel"
type="number"
step="any"
class="form-control form-control-sm"
placeholder="Rel"
/>
</td>
<td>
<input
v-model.number="createForm.rel_c"
type="number"
step="any"
class="form-control form-control-sm"
placeholder="RelC"
/>
</td>
<td>
<input
v-model.number="createForm.le"
type="number"
step="any"
class="form-control form-control-sm"
placeholder="Le"
/>
</td>
<td>
<input
v-model.number="createForm.pr"
type="number"
step="any"
class="form-control form-control-sm"
placeholder="Pr"
/>
</td>
<td>
<input
v-model.number="createForm.pe"
type="number"
step="any"
class="form-control form-control-sm"
placeholder="Pe"
/>
</td>
<td>
<input
v-model.number="createForm.ma"
type="number"
step="any"
class="form-control form-control-sm"
placeholder="Ma"
/>
</td>
<td>
<input
v-model="createForm.initial_condition"
type="text"
class="form-control form-control-sm"
placeholder="Initial"
/>
</td>
<td>
<input
v-model.number="createForm.time"
type="number"
step="any"
class="form-control form-control-sm"
placeholder="Time"
/>
</td>
<td class="text-muted small">new</td>
<td class="text-end">
<button
class="btn btn-sm btn-success"
type="button"
@click="createItem"
:disabled="saving"
>
{{ saving ? "Сохранение..." : "Добавить" }}
</button>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="11" class="text-center text-muted py-4">
Записи не найдены
</td>
</tr>
<tr
v-for="item in items"
:key="item.id"
:class="{ 'table-primary': item.id === selectedId }"
>
<td class="fw-semibold">{{ item.id }}</td>
<td>{{ item.rel }}</td>
<td>{{ item.rel_c }}</td>
<td>{{ item.le }}</td>
<td>{{ item.pr }}</td>
<td>{{ item.pe }}</td>
<td>{{ item.ma }}</td>
<td class="text-truncate" style="max-width: 180px">
{{ item.initial_condition || "—" }}
</td>
<td>{{ item.time }}</td>
<td class="text-truncate" style="max-width: 220px">
{{ item.folder_path || "—" }}
</td>
<td class="text-end">
<div class="btn-group btn-group-sm" role="group">
<button
class="btn btn-outline-primary"
type="button"
@click="selectItem(item)"
>
Редактировать
</button>
<button
class="btn btn-success"
type="button"
@click="launchParams(item)"
:disabled="launchingId === item.id"
>
{{ launchingId === item.id ? 'Запуск...' : 'Запустить' }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 p-3 border-top">
<div class="text-muted small">
Показано {{ pageStart }}-{{ pageEnd }} из {{ totalCount }}
</div>
<div class="btn-group btn-group-sm" role="group" aria-label="Pagination">
<button class="btn btn-outline-secondary" type="button" @click="goToPage(page - 1)" :disabled="page <= 1">
Назад
</button>
<button class="btn btn-outline-secondary" type="button" disabled>
{{ page }} / {{ totalPages }}
</button>
<button class="btn btn-outline-secondary" type="button" @click="goToPage(page + 1)" :disabled="page >= totalPages">
Вперёд
</button>
</div>
</div>
</div>
</div>
<div v-if="selectedItem" class="card shadow-sm">
<div
class="card-header bg-white d-flex justify-content-between align-items-center flex-wrap gap-2"
>
<div>
<div class="text-muted small">Редактирование</div>
<div class="fw-semibold">Params #{{ selectedItem.id }}</div>
</div>
<div class="text-muted small">
Created: {{ formatDate(selectedItem.created_at) }}
</div>
</div>
<div class="card-body">
<form class="row g-3" @submit.prevent="saveItem">
<div class="col-md-2">
<label class="form-label">rel</label>
<input v-model="form.rel" type="number" step="any" class="form-control" />
</div>
<div class="col-md-2">
<label class="form-label">rel_c</label>
<input v-model="form.rel_c" type="number" step="any" class="form-control" />
</div>
<div class="col-md-2">
<label class="form-label">le</label>
<input v-model="form.le" type="number" step="any" class="form-control" />
</div>
<div class="col-md-2">
<label class="form-label">pr</label>
<input v-model="form.pr" type="number" step="any" class="form-control" />
</div>
<div class="col-md-2">
<label class="form-label">pe</label>
<input
v-model="form.pe"
type="number"
step="any"
class="form-control"
/>
</div>
<div class="col-md-2">
<label class="form-label">ma</label>
<input
v-model="form.ma"
type="number"
step="any"
class="form-control"
/>
</div>
<div class="col-md-2">
<label class="form-label">time</label>
<input
v-model="form.time"
type="number"
step="any"
class="form-control"
/>
</div>
<div class="col-12">
<label class="form-label">initial_condition</label>
<input
v-model="form.initial_condition"
type="text"
class="form-control"
/>
</div>
<div class="col-12">
<label class="form-label">folder_path</label>
<input v-model="form.folder_path" type="text" class="form-control" />
</div>
<div class="col-12 d-flex justify-content-end gap-2">
<button class="btn btn-primary" type="submit" :disabled="saving">
{{ saving ? "Сохранение..." : "Сохранить" }}
</button>
</div>
</form>
</div>
</div>
</section> </section>
</template> </template>
+213 -12
View File
@@ -3,15 +3,16 @@ package control_case
import ( import (
"encoding/csv" "encoding/csv"
"fmt" "fmt"
"math"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings" "strings"
"control/analize"
"github.com/che4web/go4rest" "github.com/che4web/go4rest"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gonum.org/v1/gonum/dsp/fourier"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -22,7 +23,7 @@ type ControlCaseController struct {
func NewControlCaseController(db *gorm.DB) *ControlCaseController { func NewControlCaseController(db *gorm.DB) *ControlCaseController {
viewSet := go4rest.NewViewSet[ControlCase](db) viewSet := go4rest.NewViewSet[ControlCase](db)
viewSet.PreloadField = []string{"Params"} viewSet.PreloadField = []string{"Params", "Params.InitialCondition"}
return &ControlCaseController{ return &ControlCaseController{
ViewSet: viewSet, ViewSet: viewSet,
@@ -35,9 +36,24 @@ type ParamsController struct {
db *gorm.DB db *gorm.DB
} }
type InitialConditionController struct {
*go4rest.ViewSet[InitialCondition]
db *gorm.DB
}
func NewParamsController(db *gorm.DB) *ParamsController { func NewParamsController(db *gorm.DB) *ParamsController {
viewSet := go4rest.NewViewSet[Params](db)
viewSet.PreloadField = []string{"InitialCondition"}
return &ParamsController{ return &ParamsController{
ViewSet: go4rest.NewViewSet[Params](db), ViewSet: viewSet,
db: db,
}
}
func NewInitialConditionController(db *gorm.DB) *InitialConditionController {
return &InitialConditionController{
ViewSet: go4rest.NewViewSet[InitialCondition](db),
db: db, db: db,
} }
} }
@@ -75,7 +91,7 @@ func (c *ControlCaseController) Launch(ctx *gin.Context) {
return return
} }
if err := c.db.Preload("Params").First(&controlCase, controlCase.ID).Error; err != nil { if err := c.db.Preload("Params").Preload("Params.InitialCondition").First(&controlCase, controlCase.ID).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -88,6 +104,20 @@ type CSVSeriesResponse struct {
Rows [][]interface{} `json:"rows"` Rows [][]interface{} `json:"rows"`
} }
type FFTPoint struct {
Frequency float64 `json:"frequency"`
Amplitude float64 `json:"amplitude"`
}
type PSISpectrumResponse struct {
TimeStep float64 `json:"time_step"`
Points map[string][]FFTPoint `json:"points"`
}
type InitialConditionResponse struct {
InitialCondition InitialCondition `json:"initial_condition"`
}
func (c *ControlCaseController) ChartData(ctx *gin.Context) { func (c *ControlCaseController) ChartData(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil { if err != nil {
@@ -101,7 +131,7 @@ func (c *ControlCaseController) ChartData(ctx *gin.Context) {
return return
} }
csvPath := filepath.Join(".", strconv.FormatUint(uint64(controlCase.ParamsID), 10), "foo.csv") csvPath := controlCase.FooCSVPath()
file, err := os.Open(csvPath) file, err := os.Open(csvPath)
if err != nil { if err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "csv file not found"}) ctx.JSON(http.StatusNotFound, gin.H{"error": "csv file not found"})
@@ -138,7 +168,7 @@ func (c *ControlCaseController) ChartData(ctx *gin.Context) {
ctx.JSON(http.StatusOK, CSVSeriesResponse{Columns: columns, Rows: rows}) ctx.JSON(http.StatusOK, CSVSeriesResponse{Columns: columns, Rows: rows})
} }
func (c *ControlCaseController) RecalculateAnalysis(ctx *gin.Context) { func (c *ControlCaseController) PSISpectrum(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32) id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil { if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"}) ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
@@ -146,19 +176,160 @@ func (c *ControlCaseController) RecalculateAnalysis(ctx *gin.Context) {
} }
var controlCase ControlCase var controlCase ControlCase
if err := c.db.First(&controlCase, id).Error; err != nil { if err := c.db.Preload("Params").First(&controlCase, id).Error; err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "record not found"}) ctx.JSON(http.StatusNotFound, gin.H{"error": "record not found"})
return return
} }
csvPath := filepath.Join(".", strconv.FormatUint(uint64(controlCase.ParamsID), 10), "foo.csv") csvPath := controlCase.FooCSVPath()
result, err := analize.AnalyzeCSV(c.db, controlCase.ID, controlCase.Name, csvPath) file, err := os.Open(csvPath)
if err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "csv file not found"})
return
}
defer file.Close()
reader := csv.NewReader(file)
records, err := reader.ReadAll()
if err != nil { if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
if len(records) < 2 {
ctx.JSON(http.StatusOK, PSISpectrumResponse{Points: map[string][]FFTPoint{}})
return
}
ctx.JSON(http.StatusOK, result) headers := make(map[string]int, len(records[0]))
for i, header := range records[0] {
headers[strings.ToLower(strings.TrimSpace(header))] = i
}
getIndex := func(name string) (int, error) {
idx, ok := headers[strings.ToLower(name)]
if !ok {
return -1, fmt.Errorf("missing column %q", name)
}
return idx, nil
}
tIndex, err := getIndex("t")
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
psiMIndex, err := getIndex("psi_m")
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
psiLIndex, err := getIndex("psi_l")
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
times := make([]float64, 0, len(records)-1)
psiM := make([]float64, 0, len(records)-1)
psiL := make([]float64, 0, len(records)-1)
for _, record := range records[1:] {
if len(record) <= maxInt(tIndex, psiMIndex, psiLIndex) {
continue
}
t, err := parseFloat(record[tIndex])
if err != nil {
continue
}
m, err := parseFloat(record[psiMIndex])
if err != nil {
continue
}
l, err := parseFloat(record[psiLIndex])
if err != nil {
continue
}
times = append(times, t)
psiM = append(psiM, m)
psiL = append(psiL, l)
}
timeStep := averageDelta(times)
if timeStep <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid time step"})
return
}
ctx.JSON(http.StatusOK, PSISpectrumResponse{
TimeStep: timeStep,
Points: map[string][]FFTPoint{
"psi_m": buildSpectrumPoints(psiM, timeStep),
"psi_l": buildSpectrumPoints(psiL, timeStep),
},
})
}
func buildSpectrumPoints(values []float64, dt float64) []FFTPoint {
if len(values) < 2 || dt <= 0 {
return []FFTPoint{}
}
centered := make([]float64, len(values))
mean := 0.0
for _, v := range values {
mean += v
}
mean /= float64(len(values))
for i, v := range values {
centered[i] = v - mean
}
fft := fourier.NewFFT(len(centered))
coeffs := fft.Coefficients(nil, centered)
limit := len(coeffs) / 2
points := make([]FFTPoint, 0, limit+1)
for i := 0; i <= limit; i++ {
points = append(points, FFTPoint{
Frequency: float64(i) / (float64(len(centered)) * dt),
Amplitude: math.Hypot(real(coeffs[i]), imag(coeffs[i])) / float64(len(centered)),
})
}
return points
}
func parseFloat(value string) (float64, error) {
return strconv.ParseFloat(strings.TrimSpace(value), 64)
}
func maxInt(values ...int) int {
max := 0
for _, v := range values {
if v > max {
max = v
}
}
return max
}
func averageDelta(times []float64) float64 {
if len(times) < 2 {
return 0
}
total := 0.0
count := 0
for i := 1; i < len(times); i++ {
delta := times[i] - times[i-1]
if delta <= 0 {
continue
}
total += delta
count++
}
if count == 0 {
return 0
}
return total / float64(count)
} }
func (c *ControlCaseController) FieldMap(ctx *gin.Context) { func (c *ControlCaseController) FieldMap(ctx *gin.Context) {
@@ -180,8 +351,7 @@ func (c *ControlCaseController) FieldMap(ctx *gin.Context) {
return return
} }
h5Path := filepath.Join(".", strconv.FormatUint(uint64(controlCase.ParamsID), 10), "storage.h5") result, err := ReadNearestFieldMap(controlCase.StorageH5Path(), timeValue)
result, err := ReadNearestFieldMap(h5Path, timeValue)
if err != nil { if err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
@@ -189,3 +359,34 @@ func (c *ControlCaseController) FieldMap(ctx *gin.Context) {
ctx.JSON(http.StatusOK, result) ctx.JSON(http.StatusOK, result)
} }
func (c *ControlCaseController) CreateInitialCondition(ctx *gin.Context) {
id, err := strconv.ParseUint(ctx.Param("id"), 10, 32)
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
return
}
var controlCase ControlCase
if err := c.db.First(&controlCase, id).Error; err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "record not found"})
return
}
outputPath := filepath.Join(BASE_DIR, "initial_conditions", fmt.Sprintf("control_case_%d", controlCase.ID), "storage.h5")
if err := controlCase.CreateStorageH5FromLastState(outputPath); err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
initialCondition := InitialCondition{
Name: fmt.Sprintf("control-case-%d", controlCase.ID),
FilePath: outputPath,
}
if err := c.db.Where("file_path = ?", outputPath).Assign(initialCondition).FirstOrCreate(&initialCondition).Error; err != nil {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
ctx.JSON(http.StatusOK, InitialConditionResponse{InitialCondition: initialCondition})
}
+75
View File
@@ -2,7 +2,9 @@ package control_case
import ( import (
"fmt" "fmt"
"os"
"os/exec" "os/exec"
"path/filepath"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@@ -70,6 +72,68 @@ func ReadNearestFieldMap(filePath string, requestedT float64) (*FieldMapResponse
return result, nil return result, nil
} }
func CopyLastStateStorageH5(sourcePath, outputPath string) error {
if strings.TrimSpace(outputPath) == "" {
return fmt.Errorf("output path is empty")
}
sourceAbs, err := filepath.Abs(sourcePath)
if err != nil {
return err
}
outputAbs, err := filepath.Abs(outputPath)
if err != nil {
return err
}
if sourceAbs == outputAbs {
return fmt.Errorf("output path must be different from source path")
}
stageGroups, err := listStageGroups(sourcePath)
if err != nil {
return err
}
if len(stageGroups) == 0 {
return fmt.Errorf("no stage_t groups found")
}
latest := stageGroups[0]
for _, stage := range stageGroups[1:] {
if stage.stageT > latest.stageT {
latest = stage
}
}
if err := os.MkdirAll(filepath.Dir(outputPath), os.ModePerm); err != nil {
return err
}
tmpFile, err := os.CreateTemp(filepath.Dir(outputPath), ".storage-*.h5")
if err != nil {
return err
}
tmpPath := tmpFile.Name()
if err := tmpFile.Close(); err != nil {
_ = os.Remove(tmpPath)
return err
}
if err := os.Remove(tmpPath); err != nil {
return err
}
if err := runH5Copy(sourcePath, tmpPath, latest.path, "/map/stage_t=0", "-p"); err != nil {
_ = os.Remove(tmpPath)
return err
}
if err := os.Rename(tmpPath, outputPath); err != nil {
_ = os.Remove(tmpPath)
return err
}
return nil
}
type stageFieldMap struct { type stageFieldMap struct {
path string path string
stageT float64 stageT float64
@@ -231,6 +295,17 @@ func runH5Dump(filePath string, args ...string) ([]byte, error) {
return out, nil return out, nil
} }
func runH5Copy(sourceFile, outputFile, sourcePath, outputPath string, args ...string) error {
cmdArgs := []string{"-i", sourceFile, "-o", outputFile, "-s", sourcePath, "-d", outputPath}
cmdArgs = append(cmdArgs, args...)
cmd := exec.Command("h5copy", cmdArgs...)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("h5copy %v failed: %w: %s", cmdArgs, err, strings.TrimSpace(string(out)))
}
return nil
}
func absFloat64(value float64) float64 { func absFloat64(value float64) float64 {
if value < 0 { if value < 0 {
return -value return -value
+44 -17
View File
@@ -21,20 +21,47 @@ type ControlCase struct {
Status string `json:"status"` Status string `json:"status"`
} }
func (c ControlCase) FooCSVPath() string {
paramsID := c.ParamsID
if paramsID == 0 {
paramsID = c.Params.ID
}
return filepath.Join(BASE_DIR, fmt.Sprintf("%d", paramsID), "foo.csv")
}
func (c ControlCase) StorageH5Path() string {
paramsID := c.ParamsID
if paramsID == 0 {
paramsID = c.Params.ID
}
return filepath.Join(BASE_DIR, fmt.Sprintf("%d", paramsID), "storage.h5")
}
func (c ControlCase) CreateStorageH5FromLastState(outputPath string) error {
return CopyLastStateStorageH5(c.StorageH5Path(), outputPath)
}
type Params struct { type Params struct {
ID uint `gorm:"primaryKey" json:"id" toml:"-" csv:"id"` ID uint `gorm:"primaryKey" json:"id" toml:"-" csv:"id"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
Rel float64 `json:"rel" toml:"rel" csv:"rel"` Rel float64 `json:"rel" toml:"rel" csv:"rel"`
RelC float64 `json:"rel_c" toml:"rel_c" csv:"rel_c"` RelC float64 `json:"rel_c" toml:"rel_c" csv:"rel_c"`
Le float64 `json:"le" toml:"le" csv:"le"` Le float64 `json:"le" toml:"le" csv:"le"`
Pr float64 `json:"pr" toml:"pr" csv:"pr"` Sc float64 `json:"sc" toml:"sc" csv:"sc"`
Pe float64 `json:"pe" toml:"pe" csv:"pe"` Pe float64 `json:"pe" toml:"pe" csv:"pe"`
Ma float64 `json:"ma" toml:"ma" csv:"ma"` Ma float64 `json:"ma" toml:"ma" csv:"ma"`
InitialCondition string `json:"initial_condition" toml:"-" csv:"initial_condition"` InitialConditionID *uint `json:"initial_condition_id" toml:"-" csv:"initial_condition_id"`
Time float64 `json:"time" toml:"time" csv:"time"` InitialCondition *InitialCondition `json:"initial_condition" toml:"-" csv:"-"`
FolderPath string `json:"folder_path" toml:"-" csv:"-"` Time float64 `json:"time" toml:"time" csv:"time"`
FolderPath string `json:"folder_path" toml:"-" csv:"-"`
}
type InitialCondition struct {
gorm.Model
Name string `json:"name"`
FilePath string `json:"file_path"`
} }
func (p *Params) ToTOML(filename string) error { func (p *Params) ToTOML(filename string) error {
@@ -49,12 +76,12 @@ func (p *Params) ToTOML(filename string) error {
} }
func (p *Params) Exist() (bool, error) { func (p *Params) Exist() (bool, error) {
folderPath := filepath.Join("./", fmt.Sprintf("%d", p.ID)) folderPath := filepath.Join(BASE_DIR, fmt.Sprintf("%d", p.ID))
return exists(folderPath) return exists(folderPath)
} }
func (p *Params) CreateFolder() error { func (p *Params) CreateFolder() error {
BIN_PATH := "./bio-convect" BIN_PATH := "./bio-convect"
folderPath := filepath.Join("./", fmt.Sprintf("%d", p.ID)) folderPath := filepath.Join(BASE_DIR, fmt.Sprintf("%d", p.ID))
if err := os.MkdirAll(folderPath, os.ModePerm); err != nil { if err := os.MkdirAll(folderPath, os.ModePerm); err != nil {
return err return err
} }
@@ -67,9 +94,9 @@ func (p *Params) CreateFolder() error {
if err := os.Chmod(execPath, 0o755); err != nil { if err := os.Chmod(execPath, 0o755); err != nil {
return err return err
} }
if p.InitialCondition != "" && p.InitialCondition != "E" { if p.InitialCondition != nil && p.InitialCondition.FilePath != "" {
startPath := filepath.Join("./", folderPath, "storage.h5") startPath := filepath.Join("./", folderPath, "storage.h5")
err = copy.Copy(p.InitialCondition, startPath) err = copy.Copy(p.InitialCondition.FilePath, startPath)
if err != nil { if err != nil {
return err return err
} }
+32 -1
View File
@@ -8,19 +8,50 @@ import (
func RegisterApp(r *gin.Engine, db *gorm.DB) { func RegisterApp(r *gin.Engine, db *gorm.DB) {
db.AutoMigrate(&ControlCase{}) db.AutoMigrate(&ControlCase{})
db.AutoMigrate(&InitialCondition{})
db.AutoMigrate(&Params{}) db.AutoMigrate(&Params{})
migrateParamsPrToSc(db)
controller := NewControlCaseController(db) controller := NewControlCaseController(db)
params := NewParamsController(db) params := NewParamsController(db)
initialConditions := NewInitialConditionController(db)
go4rest.RegisterCRUDRoutes(r, "control_case", controller) go4rest.RegisterCRUDRoutes(r, "control_case", controller)
go4rest.RegisterCRUDRoutes(r, "params", params) go4rest.RegisterCRUDRoutes(r, "params", params)
go4rest.RegisterCRUDRoutes(r, "initial_condition", initialConditions)
r.POST("/api/control_case/launch", controller.Launch) r.POST("/api/control_case/launch", controller.Launch)
r.POST("/api/control_case/:id/recalculate-analysis", controller.RecalculateAnalysis)
r.GET("/api/control_case/:id/chart-data", controller.ChartData) r.GET("/api/control_case/:id/chart-data", controller.ChartData)
r.GET("/api/control_case/:id/psi-spectrum", controller.PSISpectrum)
r.GET("/api/control_case/:id/field-map", controller.FieldMap) r.GET("/api/control_case/:id/field-map", controller.FieldMap)
r.POST("/api/control_case/:id/initial-condition", controller.CreateInitialCondition)
numJobs := 1 numJobs := 1
//jobs := make(chan Params, numJobs) //jobs := make(chan Params, numJobs)
results := make(chan int, numJobs) results := make(chan int, numJobs)
go RunControllWorker(db, results) go RunControllWorker(db, results)
} }
func migrateParamsPrToSc(db *gorm.DB) {
type columnInfo struct {
Name string `gorm:"column:name"`
}
var columns []columnInfo
if err := db.Raw("PRAGMA table_info(params)").Scan(&columns).Error; err != nil {
return
}
hasPr := false
hasSc := false
for _, column := range columns {
switch column.Name {
case "pr":
hasPr = true
case "sc":
hasSc = true
}
}
if hasPr && hasSc {
db.Exec("UPDATE params SET sc = pr WHERE sc IS NULL OR sc = 0")
}
}
+4 -8
View File
@@ -10,7 +10,6 @@ import (
"sync" "sync"
"time" "time"
"control/analize"
"github.com/gocarina/gocsv" "github.com/gocarina/gocsv"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -26,11 +25,13 @@ func exists(path string) (bool, error) {
return false, err return false, err
} }
const BASE_DIR = "./data"
func worker(id int, jobs <-chan ControlCase, ctx context.Context, db *gorm.DB) { func worker(id int, jobs <-chan ControlCase, ctx context.Context, db *gorm.DB) {
for j := range jobs { for j := range jobs {
fmt.Printf("Worker %d started job %d \n", id, j.ID) fmt.Printf("Worker %d started job %d \n", id, j.ID)
p := j.Params p := j.Params
p.FolderPath = filepath.Join(".", fmt.Sprintf("%d", p.ID)) p.FolderPath = filepath.Join(BASE_DIR, fmt.Sprintf("%d", p.ID))
exists, err := p.Exist() exists, err := p.Exist()
if err != nil { if err != nil {
@@ -49,12 +50,6 @@ func worker(id int, jobs <-chan ControlCase, ctx context.Context, db *gorm.DB) {
} }
} }
csvPath := filepath.Join(p.FolderPath, "foo.csv")
if _, err := analize.AnalyzeCSV(db, j.ID, j.Name, csvPath); err != nil {
fmt.Printf("Worker %d failed to analyze job %d: %v\n", id, j.ID, err)
continue
}
j.Status = "R" j.Status = "R"
if err := db.Model(&ControlCase{}).Where("id = ?", j.ID).Update("status", j.Status).Error; err != nil { if err := db.Model(&ControlCase{}).Where("id = ?", j.ID).Update("status", j.Status).Error; err != nil {
fmt.Printf("Worker %d failed to update status for job %d: %v\n", id, j.ID, err) fmt.Printf("Worker %d failed to update status for job %d: %v\n", id, j.ID, err)
@@ -92,6 +87,7 @@ func readFromDb(db *gorm.DB) []ControlCase {
_ = db. _ = db.
Where("status IN ?", statuses). Where("status IN ?", statuses).
Preload("Params"). Preload("Params").
Preload("Params.InitialCondition").
Find(&cases).Error Find(&cases).Error
return cases return cases
} }
+3 -1
View File
@@ -1,6 +1,6 @@
module control module control
go 1.24.2 go 1.25
require ( require (
github.com/gin-gonic/gin v1.10.1 github.com/gin-gonic/gin v1.10.1
@@ -41,6 +41,7 @@ require (
github.com/otiai10/mint v1.6.3 // indirect github.com/otiai10/mint v1.6.3 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/scigolib/hdf5 v0.14.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
@@ -50,6 +51,7 @@ require (
golang.org/x/sync v0.16.0 // indirect golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/sys v0.35.0 // indirect
golang.org/x/text v0.28.0 // indirect golang.org/x/text v0.28.0 // indirect
gonum.org/v1/gonum v0.17.0 // indirect
google.golang.org/protobuf v1.36.7 // indirect google.golang.org/protobuf v1.36.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.22.5 // indirect modernc.org/libc v1.22.5 // indirect
+4
View File
@@ -93,6 +93,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/scigolib/hdf5 v0.14.0 h1:gTuF00UvaPEXvMOs/Lek13fRKkgSIBnibuFsnD4D/ys=
github.com/scigolib/hdf5 v0.14.0/go.mod h1:7KLvpsidPPQjmd83dKH8RazoKXdbCO+FItz7ksezhrY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -137,6 +139,8 @@ golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg=
google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=