diff --git a/analize/controllers.go b/analize/controllers.go new file mode 100644 index 0000000..3f5080a --- /dev/null +++ b/analize/controllers.go @@ -0,0 +1,147 @@ +package analize + +import ( + "fmt" + "net/http" + "path/filepath" + "strconv" + "strings" + + "github.com/che4web/go4rest" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +type AnalizeController struct { + *go4rest.ViewSet[Analize] + db *gorm.DB +} + +func NewAnalizeController(db *gorm.DB) *AnalizeController { + return &AnalizeController{ + ViewSet: go4rest.NewViewSet[Analize](db), + db: db, + } +} + +type AnalizeSeriesPoint struct { + CaseID uint `json:"case_id"` + CaseName string `json:"case_name"` + X float64 `json:"x"` + Omega float64 `json:"omega"` + PsiMax float64 `json:"psi_max"` +} + +type AnalizeSeriesResponse struct { + Parameter string `json:"parameter"` + Label string `json:"label"` + Points []AnalizeSeriesPoint `json:"points"` +} + +var analizeParameterColumns = map[string]struct { + Column string + Label string +}{ + "Rel": {Column: "rel", Label: "Rel"}, + "RelC": {Column: "rel_c", Label: "RelC"}, + "Le": {Column: "le", Label: "Le"}, + "Pr": {Column: "pr", Label: "Pr"}, + "Pe": {Column: "pe", Label: "Pe"}, + "Ma": {Column: "ma", Label: "Ma"}, + "Time": {Column: "time", Label: "Time"}, +} + +func (c *AnalizeController) Series(ctx *gin.Context) { + param := ctx.DefaultQuery("parameter", "Rel") + selected, ok := analizeParameterColumns[param] + if !ok { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid parameter"}) + return + } + + filterParam := ctx.Query("filter_parameter") + filterSelected, filterOK := analizeParameterColumns[filterParam] + filterMin := ctx.Query("filter_min") + filterMax := ctx.Query("filter_max") + + type row struct { + CaseID uint `gorm:"column:case_id"` + CaseName string `gorm:"column:case_name"` + X float64 `gorm:"column:x"` + Omega float64 `gorm:"column:omega"` + PsiMax float64 `gorm:"column:psi_max"` + } + + query := fmt.Sprintf(` + SELECT a.case_id, a.case_name, p.%s AS x, a.omega, a.psi_max + FROM analizes a + JOIN control_cases c ON c.id = a.case_id + JOIN params p ON p.id = c.params_id + `, selected.Column) + where := make([]string, 0, 2) + args := make([]any, 0, 2) + if filterParam != "" { + if !filterOK { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid filter parameter"}) + return + } + if filterMin != "" { + where = append(where, fmt.Sprintf("p.%s >= ?", filterSelected.Column)) + args = append(args, filterMin) + } + if filterMax != "" { + where = append(where, fmt.Sprintf("p.%s <= ?", filterSelected.Column)) + args = append(args, filterMax) + } + } + if len(where) > 0 { + query += " WHERE " + strings.Join(where, " AND ") + } + query += fmt.Sprintf(" ORDER BY p.%s, a.id", selected.Column) + + var rows []row + if err := c.db.Raw(query, args...).Scan(&rows).Error; err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + points := make([]AnalizeSeriesPoint, 0, len(rows)) + for _, r := range rows { + points = append(points, AnalizeSeriesPoint{ + CaseID: r.CaseID, + CaseName: r.CaseName, + X: r.X, + Omega: r.Omega, + PsiMax: r.PsiMax, + }) + } + + ctx.JSON(http.StatusOK, AnalizeSeriesResponse{ + Parameter: param, + Label: selected.Label, + Points: points, + }) +} + +func (c *AnalizeController) Recalculate(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 item Analize + if err := c.db.First(&item, id).Error; err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "record not found"}) + return + } + + csvPath := filepath.Join(".", fmt.Sprintf("%d", item.CaseID), "foo.csv") + updated, err := AnalyzeCSV(c.db, item.CaseID, item.CaseName, csvPath) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, updated) +} diff --git a/analize/models.go b/analize/models.go new file mode 100644 index 0000000..0bdad2a --- /dev/null +++ b/analize/models.go @@ -0,0 +1,13 @@ +package analize + +import "gorm.io/gorm" + +type Analize struct { + gorm.Model + Name string `json:"name"` + CaseID uint `json:"case_id"` + CaseName string `json:"case_name"` + PsiMax float64 `json:"psi_max"` + PsiLMax float64 `json:"psi_l_max"` + Omega float64 `json:"omega"` +} diff --git a/analize/routers.go b/analize/routers.go new file mode 100644 index 0000000..e251c3f --- /dev/null +++ b/analize/routers.go @@ -0,0 +1,16 @@ +package analize + +import ( + "github.com/che4web/go4rest" + "github.com/gin-gonic/gin" + "gorm.io/gorm" +) + +func RegisterApp(r *gin.Engine, db *gorm.DB) { + db.AutoMigrate(&Analize{}) + + controller := NewAnalizeController(db) + go4rest.RegisterCRUDRoutes(r, "analize", controller) + r.GET("/api/analize/series", controller.Series) + r.POST("/api/analize/:id/recalculate", controller.Recalculate) +} diff --git a/analize/service.go b/analize/service.go new file mode 100644 index 0000000..7234310 --- /dev/null +++ b/analize/service.go @@ -0,0 +1,210 @@ +package analize + +import ( + "encoding/csv" + "errors" + "fmt" + "math" + "os" + "strconv" + "strings" + + "gonum.org/v1/gonum/dsp/fourier" + "gorm.io/gorm" +) + +func AnalyzeCSV(db *gorm.DB, caseID uint, caseName, csvPath string) (*Analize, error) { + file, err := os.Open(csvPath) + if err != nil { + return nil, err + } + defer file.Close() + + reader := csv.NewReader(file) + records, err := reader.ReadAll() + if err != nil { + return nil, err + } + if len(records) < 2 { + return nil, fmt.Errorf("csv has no data rows") + } + + 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 { + return nil, err + } + psiMIndex, err := getIndex("psi_m") + if err != nil { + return nil, err + } + psiLIndex, err := getIndex("psi_l") + if err != nil { + return nil, err + } + + 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) + } + + if len(psiL) == 0 { + return nil, fmt.Errorf("no numeric rows found in csv") + } + + psiMax := maxFloat(psiM) + psiLMax := maxFloat(psiL) + omega := mainAngularFrequency(times, psiL) + + result := &Analize{ + Name: fmt.Sprintf("analysis-%d", caseID), + CaseID: caseID, + CaseName: caseName, + PsiMax: psiMax, + PsiLMax: psiLMax, + Omega: omega, + } + + var existing Analize + err = db.Where("case_id = ?", caseID).First(&existing).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + if err := db.Create(result).Error; err != nil { + return nil, err + } + return result, nil + } + return nil, err + } + + result.ID = existing.ID + if err := db.Save(result).Error; err != nil { + return nil, err + } + return result, nil +} + +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 maxFloat(values []float64) float64 { + if len(values) == 0 { + return 0 + } + max := values[0] + for _, v := range values[1:] { + if v > max { + max = v + } + } + return max +} + +func mainAngularFrequency(times, values []float64) float64 { + if len(values) < 2 || len(times) < 2 { + return 0 + } + + dt := averageDelta(times) + if dt <= 0 { + return 0 + } + + 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) + if len(coeffs) < 2 { + return 0 + } + + bestIndex := 1 + bestAmp := 0.0 + for i := 1; i < len(coeffs); i++ { + amp := absComplex(coeffs[i]) + if amp > bestAmp { + bestAmp = amp + bestIndex = i + } + } + + frequencyHz := fft.Freq(bestIndex) / dt + return 2 * math.Pi * frequencyHz +} + +func absComplex(v complex128) float64 { + return math.Hypot(real(v), imag(v)) +} + +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) +} diff --git a/control-ui/package-lock.json b/control-ui/package-lock.json index 6a9fab9..35c1619 100644 --- a/control-ui/package-lock.json +++ b/control-ui/package-lock.json @@ -9,6 +9,8 @@ "version": "0.0.0", "dependencies": { "axios": "^1.18.1", + "bootstrap": "^5.3.8", + "echarts": "^6.1.0", "pinia": "^3.0.4", "vue": "beta", "vue-router": "^5.1.0" @@ -594,6 +596,17 @@ "dev": true, "license": "MIT" }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "peer": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", @@ -1383,6 +1396,25 @@ "url": "https://github.com/sponsors/antfu" } }, + "node_modules/bootstrap": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.8.tgz", + "integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/twbs" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + } + ], + "license": "MIT", + "peerDependencies": { + "@popperjs/core": "^2.11.8" + } + }, "node_modules/browserslist": { "version": "4.28.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", @@ -1659,6 +1691,22 @@ "node": ">= 0.4" } }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, "node_modules/electron-to-chromium": { "version": "1.5.389", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", @@ -3571,6 +3619,21 @@ "funding": { "url": "https://github.com/sponsors/eemeli" } + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" } } } diff --git a/control-ui/package.json b/control-ui/package.json index cd62939..3291a47 100644 --- a/control-ui/package.json +++ b/control-ui/package.json @@ -12,6 +12,8 @@ }, "dependencies": { "axios": "^1.18.1", + "bootstrap": "^5.3.8", + "echarts": "^6.1.0", "pinia": "^3.0.4", "vue": "beta", "vue-router": "^5.1.0" diff --git a/control-ui/src/App.vue b/control-ui/src/App.vue index 73b8494..7ade7f7 100644 --- a/control-ui/src/App.vue +++ b/control-ui/src/App.vue @@ -1,19 +1,33 @@ +
+ - +
+ +
+
+ diff --git a/control-ui/src/api.ts b/control-ui/src/api.ts index 43c9ff3..1fc9f82 100644 --- a/control-ui/src/api.ts +++ b/control-ui/src/api.ts @@ -1,5 +1,89 @@ -import { createModelApi } from "@/api_client.ts"; -import type { Params, ControlCase } from "@/models.ts"; +import { createModelApi, apiClient } from "@/api_client.ts"; +import type { Params, ControlCase, Analize } from "@/models.ts"; export const paramsApi = createModelApi("params"); -export const controlCaseApi = createModelApi("control-case"); + +export const controlCaseApi = createModelApi("control_case"); +export const analizeApi = createModelApi("analize"); + +export interface CsvChartResponse { + columns: string[]; + rows: Array>; +} + +export interface FieldMapResponse { + requested_t: number; + stage_t: number; + rows: number; + cols: number; + fields: Record; +} + +export async function getControlCaseChartData(id: number) { + const response = await apiClient.get( + `/control_case/${id}/chart-data`, + ); + return response.data; +} + +export async function getControlCaseFieldMap(id: number, time: number) { + const response = await apiClient.get( + `/control_case/${id}/field-map`, + { + params: { time }, + }, + ); + return response.data; +} + +export interface LaunchControlCasePayload { + params_id: number; + name?: string; +} + +export async function launchControlCase(payload: LaunchControlCasePayload) { + const response = await apiClient.post( + "/control_case/launch", + payload, + ); + return response.data; +} + +export async function recalculateControlCaseAnalysis(id: number) { + const response = await apiClient.post( + `/control_case/${id}/recalculate-analysis`, + ); + return response.data; +} + +export async function recalculateAnalize(id: number) { + const response = await apiClient.post(`/analize/${id}/recalculate`); + return response.data; +} + +export interface AnalizeSeriesPoint { + case_id: number; + case_name: string; + x: number; + omega: number; + psi_max: number; +} + +export interface AnalizeSeriesResponse { + parameter: string; + label: string; + points: AnalizeSeriesPoint[]; +} + +export interface AnalizeSeriesFilters { + filter_parameter?: string; + filter_min?: number; + filter_max?: number; +} + +export async function getAnalizeSeries(parameter: string, filters?: AnalizeSeriesFilters) { + const response = await apiClient.get("/analize/series", { + params: { parameter, ...(filters ?? {}) }, + }); + return response.data; +} diff --git a/control-ui/src/api_client.ts b/control-ui/src/api_client.ts index 48407e7..9740483 100644 --- a/control-ui/src/api_client.ts +++ b/control-ui/src/api_client.ts @@ -1,12 +1,12 @@ import axios from "axios"; export interface BaseEntity { - id: number; + id?: number; + ID?: number; } export interface ListParams { page?: number; - page_size?: number; search?: string; ordering?: string; [key: string]: string | number | boolean | undefined; @@ -121,7 +121,6 @@ export function createModelApi< params: { ...(params ?? {}), page: 1, - page_size: params?.page_size ?? 100, }, }, ); diff --git a/control-ui/src/main.ts b/control-ui/src/main.ts index fda1e6e..2097264 100644 --- a/control-ui/src/main.ts +++ b/control-ui/src/main.ts @@ -1,5 +1,6 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' +import 'bootstrap/dist/css/bootstrap.min.css' import App from './App.vue' import router from './router' diff --git a/control-ui/src/models.ts b/control-ui/src/models.ts index b9edb40..aa5f560 100644 --- a/control-ui/src/models.ts +++ b/control-ui/src/models.ts @@ -1,23 +1,44 @@ +export interface GormDeletedAt { + Time: string; + Valid: boolean; +} + export interface Params { id: number; + created_at: string; + updated_at: string; + deleted_at: GormDeletedAt | null; rel: number; - relC: number; + rel_c: number; le: number; pr: number; pe: number; - initialCondition: string; + ma: number; + initial_condition: string; time: number; - folderPath: string; + folder_path: string; } export interface ControlCase { - id: number; - createdAt: string; - updatedAt: string; - deletedAt: string | null; - - name: string; - paramsId: number; - params: Params; - status: string; + ID: number; + CreatedAt: string; + UpdatedAt: string; + DeletedAt: GormDeletedAt | null; + Name: string; + ParamsID: number; + Params: Params; + Status: string; +} + +export interface Analize { + ID: number; + CreatedAt: string; + UpdatedAt: string; + DeletedAt: GormDeletedAt | null; + Name: string; + CaseID: number; + CaseName: string; + PsiMax: number; + PsiLMax: number; + Omega: number; } diff --git a/control-ui/src/router/index.ts b/control-ui/src/router/index.ts index e1eab52..25f2866 100644 --- a/control-ui/src/router/index.ts +++ b/control-ui/src/router/index.ts @@ -2,7 +2,44 @@ import { createRouter, createWebHistory } from 'vue-router' const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), - routes: [], + routes: [ + { + path: '/', + redirect: '/control-cases', + }, + { + path: '/control-cases', + name: 'control-cases', + component: () => import('@/views/ControlCaseListView.vue'), + }, + { + path: '/control-cases/:id', + name: 'control-case-detail', + component: () => import('@/views/ControlCaseDetailView.vue'), + props: true, + }, + { + path: '/control-cases/:id/chart', + name: 'control-case-chart', + component: () => import('@/views/ControlCaseChartView.vue'), + props: true, + }, + { + path: '/params', + name: 'params-list', + component: () => import('@/views/ParamsListView.vue'), + }, + { + path: '/analize', + name: 'analize-list', + component: () => import('@/views/AnalizeListView.vue'), + }, + { + path: '/analize/chart', + name: 'analize-chart', + component: () => import('@/views/AnalizeChartView.vue'), + }, + ], }) export default router diff --git a/control-ui/src/views/AnalizeChartView.vue b/control-ui/src/views/AnalizeChartView.vue new file mode 100644 index 0000000..26c779d --- /dev/null +++ b/control-ui/src/views/AnalizeChartView.vue @@ -0,0 +1,538 @@ + + + diff --git a/control-ui/src/views/AnalizeListView.vue b/control-ui/src/views/AnalizeListView.vue new file mode 100644 index 0000000..adc615a --- /dev/null +++ b/control-ui/src/views/AnalizeListView.vue @@ -0,0 +1,138 @@ + + + diff --git a/control-ui/src/views/ControlCaseChartView.vue b/control-ui/src/views/ControlCaseChartView.vue new file mode 100644 index 0000000..aa1a5ca --- /dev/null +++ b/control-ui/src/views/ControlCaseChartView.vue @@ -0,0 +1,122 @@ + + + diff --git a/control-ui/src/views/ControlCaseDetailView.vue b/control-ui/src/views/ControlCaseDetailView.vue new file mode 100644 index 0000000..e577c0d --- /dev/null +++ b/control-ui/src/views/ControlCaseDetailView.vue @@ -0,0 +1,162 @@ + + + diff --git a/control-ui/src/views/ControlCaseListView.vue b/control-ui/src/views/ControlCaseListView.vue new file mode 100644 index 0000000..8ec0c84 --- /dev/null +++ b/control-ui/src/views/ControlCaseListView.vue @@ -0,0 +1,131 @@ + + + diff --git a/control-ui/src/views/ParamsListView.vue b/control-ui/src/views/ParamsListView.vue new file mode 100644 index 0000000..080ae7d --- /dev/null +++ b/control-ui/src/views/ParamsListView.vue @@ -0,0 +1,646 @@ + + + diff --git a/control_case/controllers.go b/control_case/controllers.go index 3576c4b..37fa1eb 100644 --- a/control_case/controllers.go +++ b/control_case/controllers.go @@ -1,7 +1,17 @@ package control_case import ( + "encoding/csv" + "fmt" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "control/analize" "github.com/che4web/go4rest" + "github.com/gin-gonic/gin" "gorm.io/gorm" ) @@ -11,8 +21,11 @@ type ControlCaseController struct { } func NewControlCaseController(db *gorm.DB) *ControlCaseController { + viewSet := go4rest.NewViewSet[ControlCase](db) + viewSet.PreloadField = []string{"Params"} + return &ControlCaseController{ - ViewSet: go4rest.NewViewSet[ControlCase](db), + ViewSet: viewSet, db: db, } } @@ -28,3 +41,151 @@ func NewParamsController(db *gorm.DB) *ParamsController { db: db, } } + +type LaunchControlCaseRequest struct { + ParamsID uint `json:"params_id" binding:"required"` + Name string `json:"name"` +} + +func (c *ControlCaseController) Launch(ctx *gin.Context) { + var req LaunchControlCaseRequest + if err := ctx.ShouldBindJSON(&req); err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var params Params + if err := c.db.First(¶ms, req.ParamsID).Error; err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "params not found"}) + return + } + + name := req.Name + if strings.TrimSpace(name) == "" { + name = fmt.Sprintf("case-%d", params.ID) + } + + controlCase := ControlCase{ + Name: name, + ParamsID: params.ID, + Status: "N", + } + if err := c.db.Create(&controlCase).Error; err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if err := c.db.Preload("Params").First(&controlCase, controlCase.ID).Error; err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusCreated, controlCase) +} + +type CSVSeriesResponse struct { + Columns []string `json:"columns"` + Rows [][]interface{} `json:"rows"` +} + +func (c *ControlCaseController) ChartData(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.Preload("Params").First(&controlCase, id).Error; err != nil { + ctx.JSON(http.StatusNotFound, gin.H{"error": "record not found"}) + return + } + + csvPath := filepath.Join(".", strconv.FormatUint(uint64(controlCase.ParamsID), 10), "foo.csv") + 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 { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + if len(records) == 0 { + ctx.JSON(http.StatusOK, CSVSeriesResponse{Columns: []string{}, Rows: [][]interface{}{}}) + return + } + + columns := records[0] + rows := make([][]interface{}, 0, len(records)-1) + for _, record := range records[1:] { + row := make([]interface{}, 0, len(record)) + for _, value := range record { + trimmed := strings.TrimSpace(value) + if f, err := strconv.ParseFloat(trimmed, 64); err == nil { + row = append(row, f) + } else { + row = append(row, trimmed) + } + } + rows = append(rows, row) + } + + ctx.JSON(http.StatusOK, CSVSeriesResponse{Columns: columns, Rows: rows}) +} + +func (c *ControlCaseController) RecalculateAnalysis(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 + } + + csvPath := filepath.Join(".", strconv.FormatUint(uint64(controlCase.ParamsID), 10), "foo.csv") + result, err := analize.AnalyzeCSV(c.db, controlCase.ID, controlCase.Name, csvPath) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, result) +} + +func (c *ControlCaseController) FieldMap(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 + } + + timeValue, err := strconv.ParseFloat(ctx.Query("time"), 64) + if err != nil { + ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid time"}) + 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 + } + + h5Path := filepath.Join(".", strconv.FormatUint(uint64(controlCase.ParamsID), 10), "storage.h5") + result, err := ReadNearestFieldMap(h5Path, timeValue) + if err != nil { + ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx.JSON(http.StatusOK, result) +} diff --git a/control_case/hdf5.go b/control_case/hdf5.go new file mode 100644 index 0000000..502516b --- /dev/null +++ b/control_case/hdf5.go @@ -0,0 +1,239 @@ +package control_case + +import ( + "fmt" + "os/exec" + "regexp" + "strconv" + "strings" +) + +type FieldMapResponse struct { + RequestedT float64 `json:"requested_t"` + StageT float64 `json:"stage_t"` + Rows int `json:"rows"` + Cols int `json:"cols"` + Fields map[string][][]float64 `json:"fields"` +} + +var ( + stagePathRe = regexp.MustCompile(`^\s*group\s+/map/(stage_t=[^\s]+)\s*$`) + dimsRe = regexp.MustCompile(`\(\s*(\d+)\s*,\s*(\d+)\s*\)`) + floatInDataRe = regexp.MustCompile(`[-+]?(?:\d*\.\d+|\d+\.?\d*)(?:[eE][-+]?\d+)?`) +) + +func ReadNearestFieldMap(filePath string, requestedT float64) (*FieldMapResponse, error) { + stageGroups, err := listStageGroups(filePath) + if err != nil { + return nil, err + } + if len(stageGroups) == 0 { + return nil, fmt.Errorf("no stage_t groups found") + } + + selected := stageGroups[0] + for _, stage := range stageGroups[1:] { + if absFloat64(stage.stageT-requestedT) < absFloat64(selected.stageT-requestedT) { + selected = stage + } + } + + result := &FieldMapResponse{ + RequestedT: requestedT, + StageT: selected.stageT, + Fields: make(map[string][][]float64, 4), + } + + for _, fieldName := range []string{"psi", "phi", "T", "C"} { + dims, values, err := readDatasetMatrix(filePath, selected.path, fieldName) + if err != nil { + return nil, err + } + if len(dims) != 2 { + return nil, fmt.Errorf("unexpected dims for %s: %v", fieldName, dims) + } + + matrix, err := reshapeFloat64(values, dims[0], dims[1]) + if err != nil { + return nil, fmt.Errorf("failed to reshape dataset %s: %w", fieldName, err) + } + matrix = transposeFloat64(matrix) + if result.Rows == 0 && result.Cols == 0 { + result.Rows = len(matrix) + if result.Rows > 0 { + result.Cols = len(matrix[0]) + } + } + result.Fields[fieldName] = matrix + } + + return result, nil +} + +type stageFieldMap struct { + path string + stageT float64 +} + +func listStageGroups(filePath string) ([]stageFieldMap, error) { + out, err := runH5Dump(filePath, "-n") + if err != nil { + return nil, err + } + + var stages []stageFieldMap + for _, line := range strings.Split(string(out), "\n") { + matches := stagePathRe.FindStringSubmatch(line) + if len(matches) != 2 { + continue + } + stageT, err := strconv.ParseFloat(strings.TrimPrefix(matches[1], "stage_t="), 64) + if err != nil { + continue + } + stages = append(stages, stageFieldMap{ + path: "/map/" + matches[1], + stageT: stageT, + }) + } + + return stages, nil +} + +func readDatasetMatrix(filePath, stagePath, fieldName string) ([]int, []float64, error) { + datasetPath := stagePath + "/" + fieldName + + headerOut, err := runH5Dump(filePath, "-H", "-d", datasetPath) + if err != nil { + return nil, nil, err + } + + rows, cols, err := parseDimsFromHeader(headerOut) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse dims for %s: %w", datasetPath, err) + } + + dataOut, err := runH5Dump(filePath, "-d", datasetPath) + if err != nil { + return nil, nil, err + } + + values, err := parseFloatData(dataOut) + if err != nil { + return nil, nil, fmt.Errorf("failed to parse data for %s: %w", datasetPath, err) + } + + if rows*cols != len(values) { + return nil, nil, fmt.Errorf("value count %d does not match %dx%d for %s", len(values), rows, cols, datasetPath) + } + + return []int{rows, cols}, values, nil +} + +func parseDimsFromHeader(out []byte) (int, int, error) { + matches := dimsRe.FindSubmatch(out) + if len(matches) != 3 { + return 0, 0, fmt.Errorf("dimensions not found") + } + + rows, err := strconv.Atoi(string(matches[1])) + if err != nil { + return 0, 0, err + } + cols, err := strconv.Atoi(string(matches[2])) + if err != nil { + return 0, 0, err + } + return rows, cols, nil +} + +func parseFloatData(out []byte) ([]float64, error) { + lines := strings.Split(string(out), "\n") + values := make([]float64, 0, 2048) + inData := false + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + switch { + case trimmed == "DATA {": + inData = true + continue + case inData && trimmed == "}": + return values, nil + case !inData: + continue + } + + colon := strings.Index(trimmed, ":") + if colon < 0 { + continue + } + payload := strings.TrimSpace(trimmed[colon+1:]) + if payload == "" { + continue + } + + matches := floatInDataRe.FindAllString(payload, -1) + for _, match := range matches { + value, err := strconv.ParseFloat(match, 64) + if err != nil { + return nil, err + } + values = append(values, value) + } + } + + if !inData { + return nil, fmt.Errorf("data block not found") + } + return nil, fmt.Errorf("data block not terminated") +} + +func reshapeFloat64(values []float64, rows, cols int) ([][]float64, error) { + if rows*cols != len(values) { + return nil, fmt.Errorf("value count %d does not match %dx%d", len(values), rows, cols) + } + + matrix := make([][]float64, rows) + for r := 0; r < rows; r++ { + start := r * cols + row := make([]float64, cols) + copy(row, values[start:start+cols]) + matrix[r] = row + } + return matrix, nil +} + +func transposeFloat64(matrix [][]float64) [][]float64 { + if len(matrix) == 0 || len(matrix[0]) == 0 { + return matrix + } + + rows := len(matrix) + cols := len(matrix[0]) + transposed := make([][]float64, cols) + for c := 0; c < cols; c++ { + transposed[c] = make([]float64, rows) + for r := 0; r < rows; r++ { + transposed[c][r] = matrix[r][c] + } + } + return transposed +} + +func runH5Dump(filePath string, args ...string) ([]byte, error) { + cmdArgs := append(args, filePath) + cmd := exec.Command("h5dump", cmdArgs...) + out, err := cmd.CombinedOutput() + if err != nil { + return nil, fmt.Errorf("h5dump %v failed: %w: %s", cmdArgs, err, strings.TrimSpace(string(out))) + } + return out, nil +} + +func absFloat64(value float64) float64 { + if value < 0 { + return -value + } + return value +} diff --git a/control_case/models.go b/control_case/models.go index ef2808a..8848cd1 100644 --- a/control_case/models.go +++ b/control_case/models.go @@ -3,10 +3,10 @@ package control_case import ( "context" "fmt" - "log" "os" "os/exec" "path/filepath" + "time" "github.com/BurntSushi/toml" "github.com/otiai10/copy" @@ -22,16 +22,19 @@ type ControlCase struct { } type Params struct { - gorm.Model - ID uint `toml:"-" csv:"id"` - Rel float64 `toml:"rel" csv:"rel"` - RelC float64 `toml:"rel_c" csv:"rel_c"` - Le float64 `toml:"le" csv:"le"` - Pr float64 `toml:"pr" csv:"pr"` - Pe float64 `toml:"pe" csv:"pe"` - InitialCondition string `toml:"-" csv:"initial_condition"` - Time float64 `toml:"time" csv:"time"` - FolderPath string `toml:"-" csv:"-"` + ID uint `gorm:"primaryKey" json:"id" toml:"-" csv:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"` + Rel float64 `json:"rel" toml:"rel" csv:"rel"` + RelC float64 `json:"rel_c" toml:"rel_c" csv:"rel_c"` + Le float64 `json:"le" toml:"le" csv:"le"` + Pr float64 `json:"pr" toml:"pr" csv:"pr"` + Pe float64 `json:"pe" toml:"pe" csv:"pe"` + Ma float64 `json:"ma" toml:"ma" csv:"ma"` + InitialCondition string `json:"initial_condition" toml:"-" csv:"initial_condition"` + Time float64 `json:"time" toml:"time" csv:"time"` + FolderPath string `json:"folder_path" toml:"-" csv:"-"` } func (p *Params) ToTOML(filename string) error { @@ -45,37 +48,40 @@ func (p *Params) ToTOML(filename string) error { return encoder.Encode(p) } -func (u *Params) AfterCreate(tx *gorm.DB) (err error) { - control_case := ControlCase{ParamsID: u.ID, Status: "N"} - tx.Create(&control_case) - return -} func (p *Params) Exist() (bool, error) { folderPath := filepath.Join("./", fmt.Sprintf("%d", p.ID)) return exists(folderPath) } -func (p *Params) CreateFolder() { +func (p *Params) CreateFolder() error { BIN_PATH := "./bio-convect" folderPath := filepath.Join("./", fmt.Sprintf("%d", p.ID)) - os.MkdirAll(folderPath, os.ModePerm) + if err := os.MkdirAll(folderPath, os.ModePerm); err != nil { + return err + } fmt.Printf("folder: %s\n", folderPath) execPath := filepath.Join("./", folderPath, BIN_PATH) err := copy.Copy(BIN_PATH, execPath) if err != nil { - log.Fatal(err) + return err } - if p.InitialCondition != "E" { - startPath := filepath.Join("./", folderPath, "storag.h5") + if err := os.Chmod(execPath, 0o755); err != nil { + return err + } + if p.InitialCondition != "" && p.InitialCondition != "E" { + startPath := filepath.Join("./", folderPath, "storage.h5") err = copy.Copy(p.InitialCondition, startPath) if err != nil { - log.Fatal(err) + return err } } - p.ToTOML(filepath.Join(folderPath, "config.toml")) + if err := p.ToTOML(filepath.Join(folderPath, "config.toml")); err != nil { + return err + } p.FolderPath = folderPath + return nil } -func (p *Params) Run(ctx context.Context) { +func (p *Params) Run(ctx context.Context) error { BIN_PATH := "./bio-convect" cmd := exec.CommandContext(ctx, BIN_PATH) cmd.Dir = p.FolderPath @@ -86,7 +92,8 @@ func (p *Params) Run(ctx context.Context) { err := cmd.Run() fmt.Printf("afrer run") if err != nil { - log.Fatal(err) + return err } + return nil } diff --git a/control_case/routers.go b/control_case/routers.go index d81120a..332f53e 100644 --- a/control_case/routers.go +++ b/control_case/routers.go @@ -14,6 +14,10 @@ func RegisterApp(r *gin.Engine, db *gorm.DB) { params := NewParamsController(db) go4rest.RegisterCRUDRoutes(r, "control_case", controller) go4rest.RegisterCRUDRoutes(r, "params", params) + 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/field-map", controller.FieldMap) numJobs := 1 //jobs := make(chan Params, numJobs) results := make(chan int, numJobs) diff --git a/control_case/services.go b/control_case/services.go index e1e4530..004d24f 100644 --- a/control_case/services.go +++ b/control_case/services.go @@ -5,11 +5,12 @@ import ( "errors" "fmt" "io/fs" - "log" "os" + "path/filepath" "sync" "time" + "control/analize" "github.com/gocarina/gocsv" "gorm.io/gorm" ) @@ -25,11 +26,40 @@ func exists(path string) (bool, error) { return false, err } -func worker(id int, jobs <-chan Params, ctx context.Context) { +func worker(id int, jobs <-chan ControlCase, ctx context.Context, db *gorm.DB) { for j := range jobs { fmt.Printf("Worker %d started job %d \n", id, j.ID) - j.CreateFolder() - j.Run(ctx) + p := j.Params + p.FolderPath = filepath.Join(".", fmt.Sprintf("%d", p.ID)) + + exists, err := p.Exist() + if err != nil { + fmt.Printf("Worker %d failed to check folder for job %d: %v\n", id, j.ID, err) + continue + } + + if !exists { + if err := p.CreateFolder(); err != nil { + fmt.Printf("Worker %d failed to prepare job %d: %v\n", id, j.ID, err) + continue + } + if err := p.Run(ctx); err != nil { + fmt.Printf("Worker %d failed to run job %d: %v\n", id, j.ID, err) + continue + } + } + + 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" + 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) + continue + } time.Sleep(time.Second) // Имитация длительной задачи fmt.Printf("Worker %d finished job %d \n", id, j.ID) // results <- j * 2 @@ -39,13 +69,15 @@ func worker(id int, jobs <-chan Params, ctx context.Context) { func readCSVWithGocsv() []Params { file, err := os.Open("db.csv") if err != nil { - log.Fatal(err) + fmt.Printf("failed to open csv: %v\n", err) + return nil } defer file.Close() var params []Params if err := gocsv.UnmarshalFile(file, ¶ms); err != nil { - log.Fatal(err) + fmt.Printf("failed to parse csv: %v\n", err) + return nil } for _, person := range params { @@ -73,7 +105,7 @@ func RunControllWorker(db *gorm.DB, results chan int) { cancel() }() // Cancel if main exits early - jobs := make(chan Params, numJobs) + jobs := make(chan ControlCase, numJobs) //results := make(chan int, numJobs) // Запуск воркеров @@ -82,7 +114,7 @@ func RunControllWorker(db *gorm.DB, results chan int) { wg.Add(1) go func(w int) { defer wg.Done() - worker(w, jobs, ctx) + worker(w, jobs, ctx, db) }(w) } @@ -91,15 +123,7 @@ func RunControllWorker(db *gorm.DB, results chan int) { cases := readFromDb(db) // Отправка задач for _, c := range cases { - p := c.Params - e, _ := p.Exist() - if e { - c.Status = "R" - db.Save((&c)) - } else { - p.CreateFolder() - jobs <- p - } + jobs <- c } time.Sleep(10 * time.Second) } diff --git a/main.go b/main.go index 9e7ec9c..c9fb1f1 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,9 @@ package main import ( "log" + "control/analize" "control/control_case" + "github.com/gin-gonic/gin" "github.com/glebarez/sqlite" "gorm.io/gorm" @@ -19,6 +21,7 @@ func main() { _ = db control_case.RegisterApp(r, db) + analize.RegisterApp(r, db) // numJobs := 10 //jobs := make(chan control_case.Params, numJobs) //results := make(chan int, numJobs)