fix
This commit is contained in:
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
Generated
+63
@@ -9,6 +9,8 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
|
"bootstrap": "^5.3.8",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "beta",
|
"vue": "beta",
|
||||||
"vue-router": "^5.1.0"
|
"vue-router": "^5.1.0"
|
||||||
@@ -594,6 +596,17 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
"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"
|
"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": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.5",
|
"version": "4.28.5",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz",
|
||||||
@@ -1659,6 +1691,22 @@
|
|||||||
"node": ">= 0.4"
|
"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": {
|
"node_modules/electron-to-chromium": {
|
||||||
"version": "1.5.389",
|
"version": "1.5.389",
|
||||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz",
|
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz",
|
||||||
@@ -3571,6 +3619,21 @@
|
|||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.18.1",
|
"axios": "^1.18.1",
|
||||||
|
"bootstrap": "^5.3.8",
|
||||||
|
"echarts": "^6.1.0",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "beta",
|
"vue": "beta",
|
||||||
"vue-router": "^5.1.0"
|
"vue-router": "^5.1.0"
|
||||||
|
|||||||
+28
-14
@@ -1,19 +1,33 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { paramsApi } from "@/api.ts";
|
import { RouterLink, RouterView } from 'vue-router'
|
||||||
import { onMounted, onUnmounted, ref } from "vue";
|
|
||||||
const res = ref({});
|
|
||||||
onMounted(async () => {
|
|
||||||
res.value = await paramsApi.list();
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1>You did it!</h1>
|
<div class="min-vh-100 bg-light">
|
||||||
<p>
|
<nav class="navbar navbar-dark navbar-expand-lg bg-dark">
|
||||||
Visit {{ res }}
|
<div class="container-fluid">
|
||||||
<a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to
|
<RouterLink class="navbar-brand fw-semibold" to="/control-cases">
|
||||||
read the documentation
|
Control Cases
|
||||||
</p>
|
</RouterLink>
|
||||||
</template>
|
<div class="navbar-nav">
|
||||||
|
<RouterLink class="nav-link" active-class="active" to="/control-cases">
|
||||||
|
Список
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink class="nav-link" active-class="active" to="/params">
|
||||||
|
Params
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink class="nav-link" active-class="active" to="/analize">
|
||||||
|
Analize
|
||||||
|
</RouterLink>
|
||||||
|
<RouterLink class="nav-link" active-class="active" to="/analize/chart">
|
||||||
|
Analize graph
|
||||||
|
</RouterLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
<style scoped></style>
|
<main class="container py-4">
|
||||||
|
<RouterView />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|||||||
+87
-3
@@ -1,5 +1,89 @@
|
|||||||
import { createModelApi } from "@/api_client.ts";
|
import { createModelApi, apiClient } from "@/api_client.ts";
|
||||||
import type { Params, ControlCase } from "@/models.ts";
|
import type { Params, ControlCase, Analize } 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 interface CsvChartResponse {
|
||||||
|
columns: string[];
|
||||||
|
rows: Array<Array<string | number>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FieldMapResponse {
|
||||||
|
requested_t: number;
|
||||||
|
stage_t: number;
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
fields: Record<string, number[][]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getControlCaseChartData(id: number) {
|
||||||
|
const response = await apiClient.get<CsvChartResponse>(
|
||||||
|
`/control_case/${id}/chart-data`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getControlCaseFieldMap(id: number, time: number) {
|
||||||
|
const response = await apiClient.get<FieldMapResponse>(
|
||||||
|
`/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<ControlCase>(
|
||||||
|
"/control_case/launch",
|
||||||
|
payload,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recalculateControlCaseAnalysis(id: number) {
|
||||||
|
const response = await apiClient.post<Analize>(
|
||||||
|
`/control_case/${id}/recalculate-analysis`,
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function recalculateAnalize(id: number) {
|
||||||
|
const response = await apiClient.post<Analize>(`/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<AnalizeSeriesResponse>("/analize/series", {
|
||||||
|
params: { parameter, ...(filters ?? {}) },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
export interface BaseEntity {
|
export interface BaseEntity {
|
||||||
id: number;
|
id?: number;
|
||||||
|
ID?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ListParams {
|
export interface ListParams {
|
||||||
page?: number;
|
page?: number;
|
||||||
page_size?: number;
|
|
||||||
search?: string;
|
search?: string;
|
||||||
ordering?: string;
|
ordering?: string;
|
||||||
[key: string]: string | number | boolean | undefined;
|
[key: string]: string | number | boolean | undefined;
|
||||||
@@ -121,7 +121,6 @@ export function createModelApi<
|
|||||||
params: {
|
params: {
|
||||||
...(params ?? {}),
|
...(params ?? {}),
|
||||||
page: 1,
|
page: 1,
|
||||||
page_size: params?.page_size ?? 100,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createApp } from 'vue'
|
import { createApp } from 'vue'
|
||||||
import { createPinia } from 'pinia'
|
import { createPinia } from 'pinia'
|
||||||
|
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import router from './router'
|
import router from './router'
|
||||||
|
|||||||
+33
-12
@@ -1,23 +1,44 @@
|
|||||||
|
export interface GormDeletedAt {
|
||||||
|
Time: string;
|
||||||
|
Valid: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Params {
|
export interface Params {
|
||||||
id: number;
|
id: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
deleted_at: GormDeletedAt | null;
|
||||||
rel: number;
|
rel: number;
|
||||||
relC: number;
|
rel_c: number;
|
||||||
le: number;
|
le: number;
|
||||||
pr: number;
|
pr: number;
|
||||||
pe: number;
|
pe: number;
|
||||||
initialCondition: string;
|
ma: number;
|
||||||
|
initial_condition: string;
|
||||||
time: number;
|
time: number;
|
||||||
folderPath: string;
|
folder_path: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ControlCase {
|
export interface ControlCase {
|
||||||
id: number;
|
ID: number;
|
||||||
createdAt: string;
|
CreatedAt: string;
|
||||||
updatedAt: string;
|
UpdatedAt: string;
|
||||||
deletedAt: string | null;
|
DeletedAt: GormDeletedAt | null;
|
||||||
|
Name: string;
|
||||||
name: string;
|
ParamsID: number;
|
||||||
paramsId: number;
|
Params: Params;
|
||||||
params: Params;
|
Status: string;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,44 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
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
|
export default router
|
||||||
|
|||||||
@@ -0,0 +1,538 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import { controlCaseApi, getAnalizeSeries, getControlCaseChartData, getControlCaseFieldMap } from '@/api.ts'
|
||||||
|
import { formatApiError } from '@/api_client.ts'
|
||||||
|
import type { ControlCase } from '@/models.ts'
|
||||||
|
|
||||||
|
const fieldOptions = [
|
||||||
|
{ value: 'psi', label: 'psi' },
|
||||||
|
{ value: 'phi', label: 'phi' },
|
||||||
|
{ value: 'T', label: 'T' },
|
||||||
|
{ value: 'C', label: 'C' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const parameterOptions = [
|
||||||
|
{ value: 'Rel', label: 'Rel' },
|
||||||
|
{ value: 'RelC', label: 'RelC' },
|
||||||
|
{ value: 'Le', label: 'Le' },
|
||||||
|
{ value: 'Pr', label: 'Pr' },
|
||||||
|
{ value: 'Pe', label: 'Pe' },
|
||||||
|
{ value: 'Ma', label: 'Ma' },
|
||||||
|
{ value: 'Time', label: 'Time' },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
const selectedParameter = ref<(typeof parameterOptions)[number]['value']>('Rel')
|
||||||
|
const filterParameter = ref('')
|
||||||
|
const filterMin = ref('')
|
||||||
|
const filterMax = ref('')
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const containerRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const caseChartRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const chartTitle = ref('')
|
||||||
|
const seriesData = ref<{ case_id: number; x: number; omega: number; psi_max: number; case_name: string }[]>([])
|
||||||
|
const selectedCaseId = ref<number | null>(null)
|
||||||
|
const selectedCase = ref<ControlCase | null>(null)
|
||||||
|
const selectedCaseLoading = ref(false)
|
||||||
|
const selectedCaseError = ref('')
|
||||||
|
const selectedCasePoints = ref<{ t: number; psi_m: number }[]>([])
|
||||||
|
const selectedFieldMap = ref<{
|
||||||
|
requested_t: number
|
||||||
|
stage_t: number
|
||||||
|
rows: number
|
||||||
|
cols: number
|
||||||
|
fields: Record<string, number[][]>
|
||||||
|
} | null>(null)
|
||||||
|
const selectedFieldMapLoading = ref(false)
|
||||||
|
const selectedFieldMapError = ref('')
|
||||||
|
const selectedField = ref<(typeof fieldOptions)[number]['value']>('psi')
|
||||||
|
const fieldMapRef = ref<HTMLDivElement | null>(null)
|
||||||
|
let chart: echarts.ECharts | null = null
|
||||||
|
let caseChart: echarts.ECharts | null = null
|
||||||
|
let fieldMapChart: echarts.ECharts | null = null
|
||||||
|
|
||||||
|
const selectedLabel = computed(
|
||||||
|
() => parameterOptions.find((item) => item.value === selectedParameter.value)?.label ?? selectedParameter.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
function buildOption() {
|
||||||
|
return {
|
||||||
|
title: { text: `Omega / PsiMax vs ${chartTitle.value || selectedLabel.value}` },
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'item',
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const p = params.data
|
||||||
|
const valueLabel = params.seriesName === 'Omega' ? 'Omega' : 'PsiMax'
|
||||||
|
const value = params.seriesName === 'Omega' ? p.omega : p.psi_max
|
||||||
|
return [
|
||||||
|
`<strong>${p.case_name}</strong>`,
|
||||||
|
`${selectedLabel.value}: ${p.x}`,
|
||||||
|
`${valueLabel}: ${value}`,
|
||||||
|
].join('<br/>')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: { top: 28 },
|
||||||
|
grid: { left: 56, right: 32, top: 80, bottom: 56, containLabel: true },
|
||||||
|
xAxis: { type: 'value', name: selectedLabel.value },
|
||||||
|
yAxis: [
|
||||||
|
{ type: 'value', name: 'Omega', position: 'left' },
|
||||||
|
{ type: 'value', name: 'PsiMax', position: 'right' },
|
||||||
|
],
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
name: 'Omega',
|
||||||
|
type: 'scatter',
|
||||||
|
symbolSize: 10,
|
||||||
|
yAxisIndex: 0,
|
||||||
|
data: seriesData.value.map((point) => ({
|
||||||
|
case_id: point.case_id,
|
||||||
|
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',
|
||||||
|
type: 'scatter',
|
||||||
|
symbolSize: 10,
|
||||||
|
yAxisIndex: 1,
|
||||||
|
data: seriesData.value.map((point) => ({
|
||||||
|
case_id: point.case_id,
|
||||||
|
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,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart() {
|
||||||
|
if (!chart) return
|
||||||
|
chart.setOption(buildOption(), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCaseOption() {
|
||||||
|
return {
|
||||||
|
title: { text: 'psi_m / time' },
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
grid: { left: 56, right: 24, top: 48, bottom: 48, containLabel: true },
|
||||||
|
xAxis: { type: 'value', name: 't' },
|
||||||
|
yAxis: { type: 'value', name: 'psi_m' },
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
smooth: true,
|
||||||
|
showSymbol: false,
|
||||||
|
data: selectedCasePoints.value.map((point) => [point.t, point.psi_m]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCaseChart() {
|
||||||
|
if (!caseChart) return
|
||||||
|
caseChart.setOption(buildCaseOption(), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFieldMapOption() {
|
||||||
|
const payload = selectedFieldMap.value
|
||||||
|
if (!payload) return null
|
||||||
|
|
||||||
|
const matrix = payload.fields[selectedField.value] ?? []
|
||||||
|
const rows = matrix.length
|
||||||
|
const cols = matrix[0]?.length ?? 0
|
||||||
|
const seriesData: Array<[number, number, number]> = []
|
||||||
|
let min = Number.POSITIVE_INFINITY
|
||||||
|
let max = Number.NEGATIVE_INFINITY
|
||||||
|
|
||||||
|
matrix.forEach((row, y) => {
|
||||||
|
row.forEach((value, x) => {
|
||||||
|
seriesData.push([x, y, value])
|
||||||
|
if (Number.isFinite(value)) {
|
||||||
|
min = Math.min(min, value)
|
||||||
|
max = Math.max(max, value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
||||||
|
min = 0
|
||||||
|
max = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: {
|
||||||
|
text: `Field map: ${selectedField.value}`,
|
||||||
|
subtext: `requested t=${payload.requested_t}, stage t=${payload.stage_t}`,
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
position: 'top',
|
||||||
|
formatter: (params: any) => {
|
||||||
|
const [x, y, value] = params.data as [number, number, number]
|
||||||
|
return [`<strong>${selectedField.value}</strong>`, `x: ${x}`, `y: ${y}`, `value: ${value}`].join('<br/>')
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grid: { left: 56, right: 32, top: 64, bottom: 48, containLabel: true },
|
||||||
|
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 },
|
||||||
|
visualMap: {
|
||||||
|
min,
|
||||||
|
max,
|
||||||
|
calculable: true,
|
||||||
|
orient: 'horizontal',
|
||||||
|
left: 'center',
|
||||||
|
bottom: 0,
|
||||||
|
inRange: {
|
||||||
|
color: ['#0000ff', '#00ffff', '#00ff00', '#ffff00', '#ff7f00', '#ff0000'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
type: 'heatmap',
|
||||||
|
data: seriesData,
|
||||||
|
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0, 0, 0, 0.35)' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFieldMap() {
|
||||||
|
if (!fieldMapChart || !selectedFieldMap.value) return
|
||||||
|
const option = buildFieldMapOption()
|
||||||
|
if (option) {
|
||||||
|
fieldMapChart.setOption(option, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: unknown) {
|
||||||
|
if (value == null || value === '') return '—'
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value)
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function caseObject(value: ControlCase | null) {
|
||||||
|
return (value ?? {}) as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
function paramsObject(value: ControlCase | null) {
|
||||||
|
const record = caseObject(value)
|
||||||
|
return (record.Params ?? record.params ?? {}) as Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await getAnalizeSeries(selectedParameter.value, {
|
||||||
|
filter_parameter: filterParameter.value || undefined,
|
||||||
|
filter_min: filterMin.value === '' ? undefined : Number(filterMin.value),
|
||||||
|
filter_max: filterMax.value === '' ? undefined : Number(filterMax.value),
|
||||||
|
})
|
||||||
|
chartTitle.value = response.label
|
||||||
|
seriesData.value = response.points.map((point) => ({
|
||||||
|
case_id: point.case_id,
|
||||||
|
x: point.x,
|
||||||
|
omega: point.omega,
|
||||||
|
psi_max: point.psi_max,
|
||||||
|
case_name: point.case_name,
|
||||||
|
}))
|
||||||
|
renderChart()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, 'Не удалось загрузить данные графика.')
|
||||||
|
seriesData.value = []
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSelectedCase(caseId: number) {
|
||||||
|
selectedCaseId.value = caseId
|
||||||
|
selectedCaseLoading.value = true
|
||||||
|
selectedCaseError.value = ''
|
||||||
|
selectedCase.value = null
|
||||||
|
selectedCasePoints.value = []
|
||||||
|
selectedFieldMap.value = null
|
||||||
|
selectedFieldMapError.value = ''
|
||||||
|
selectedFieldMapLoading.value = false
|
||||||
|
fieldMapChart?.clear()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [caseData, csvData] = await Promise.all([
|
||||||
|
controlCaseApi.retrieve(caseId),
|
||||||
|
getControlCaseChartData(caseId),
|
||||||
|
])
|
||||||
|
|
||||||
|
selectedCase.value = caseData
|
||||||
|
|
||||||
|
const tIndex = csvData.columns.findIndex((column) => column.toLowerCase() === 't')
|
||||||
|
const psiMIndex = csvData.columns.findIndex((column) => column.toLowerCase() === 'psi_m')
|
||||||
|
|
||||||
|
if (tIndex < 0 || psiMIndex < 0) {
|
||||||
|
throw new Error('CSV does not contain t/psi_m columns')
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedCasePoints.value = csvData.rows
|
||||||
|
.map((row) => ({
|
||||||
|
t: Number(row[tIndex]),
|
||||||
|
psi_m: Number(row[psiMIndex]),
|
||||||
|
}))
|
||||||
|
.filter((point) => Number.isFinite(point.t) && Number.isFinite(point.psi_m))
|
||||||
|
|
||||||
|
renderCaseChart()
|
||||||
|
} catch (err) {
|
||||||
|
selectedCaseError.value = formatApiError(err, 'Не удалось загрузить расчетный случай.')
|
||||||
|
} finally {
|
||||||
|
selectedCaseLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFieldMap(time: number) {
|
||||||
|
if (!selectedCaseId.value) return
|
||||||
|
|
||||||
|
selectedFieldMapLoading.value = true
|
||||||
|
selectedFieldMapError.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
selectedFieldMap.value = await getControlCaseFieldMap(selectedCaseId.value, time)
|
||||||
|
renderFieldMap()
|
||||||
|
} catch (err) {
|
||||||
|
selectedFieldMapError.value = formatApiError(err, 'Не удалось загрузить карту полей.')
|
||||||
|
selectedFieldMap.value = null
|
||||||
|
fieldMapChart?.clear()
|
||||||
|
} finally {
|
||||||
|
selectedFieldMapLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
chart?.resize()
|
||||||
|
caseChart?.resize()
|
||||||
|
fieldMapChart?.resize()
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
filterParameter.value = ''
|
||||||
|
filterMin.value = ''
|
||||||
|
filterMax.value = ''
|
||||||
|
void loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (containerRef.value) {
|
||||||
|
chart = echarts.init(containerRef.value)
|
||||||
|
chart.on('click', (params: any) => {
|
||||||
|
const caseId = Number(params?.data?.case_id)
|
||||||
|
if (Number.isFinite(caseId) && caseId > 0) {
|
||||||
|
void loadSelectedCase(caseId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (caseChartRef.value) {
|
||||||
|
caseChart = echarts.init(caseChartRef.value)
|
||||||
|
caseChart.on('click', (params: any) => {
|
||||||
|
const t = Number(params?.data?.[0])
|
||||||
|
if (Number.isFinite(t)) {
|
||||||
|
void loadFieldMap(t)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (fieldMapRef.value) {
|
||||||
|
fieldMapChart = echarts.init(fieldMapRef.value)
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
}
|
||||||
|
await loadData()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(selectedParameter, loadData)
|
||||||
|
watch(selectedField, renderFieldMap)
|
||||||
|
watch(selectedFieldMap, renderFieldMap)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
chart?.dispose()
|
||||||
|
caseChart?.dispose()
|
||||||
|
fieldMapChart?.dispose()
|
||||||
|
chart = null
|
||||||
|
caseChart = null
|
||||||
|
fieldMapChart = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="d-flex flex-column gap-3">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">Analize graph</h1>
|
||||||
|
<p class="text-muted mb-0">Omega и PsiMax по выбранному параметру</p>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2 align-items-center">
|
||||||
|
<select v-model="selectedParameter" class="form-select">
|
||||||
|
<option v-for="option in parameterOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" @click="loadData" :disabled="loading">
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3 align-items-end">
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<label class="form-label">Фильтр по параметру</label>
|
||||||
|
<select v-model="filterParameter" class="form-select">
|
||||||
|
<option value="">Без фильтра</option>
|
||||||
|
<option v-for="option in parameterOptions" :key="option.value" :value="option.value">
|
||||||
|
{{ option.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<label class="form-label">Min</label>
|
||||||
|
<input v-model="filterMin" type="number" step="any" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<label class="form-label">Max</label>
|
||||||
|
<input v-model="filterMax" type="number" step="any" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-4 d-flex justify-content-end gap-2">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" @click="resetFilters" :disabled="loading">
|
||||||
|
Сбросить фильтр
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" type="button" @click="loadData" :disabled="loading">
|
||||||
|
Применить фильтр
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger mb-0" role="alert">{{ error }}</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="position-relative" style="min-height: 560px;">
|
||||||
|
<div ref="containerRef" style="height: 560px; width: 100%;"></div>
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center bg-white bg-opacity-75"
|
||||||
|
>
|
||||||
|
<div class="text-muted">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-white d-flex justify-content-between align-items-center flex-wrap gap-2">
|
||||||
|
<div class="fw-semibold">Выбранный расчетный случай</div>
|
||||||
|
<div v-if="selectedCaseId" class="text-muted small">ID {{ selectedCaseId }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div v-if="selectedCaseLoading" class="text-center text-muted py-4">Загрузка...</div>
|
||||||
|
<div v-else-if="selectedCaseError" class="alert alert-danger mb-0" role="alert">
|
||||||
|
{{ selectedCaseError }}
|
||||||
|
</div>
|
||||||
|
<template v-else-if="selectedCase">
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<div class="text-muted small">Название</div>
|
||||||
|
<div class="fw-semibold">{{ selectedCase.Name }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<div class="text-muted small">Статус</div>
|
||||||
|
<div class="fw-semibold">{{ selectedCase.Status }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<div class="text-muted small">Params ID</div>
|
||||||
|
<div class="fw-semibold">{{ selectedCase.ParamsID }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="mb-3">
|
||||||
|
<summary class="fw-semibold">ControlCase</summary>
|
||||||
|
<div class="table-responsive mt-2">
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="([key, value]) in Object.entries(caseObject(selectedCase))" :key="key">
|
||||||
|
<th class="table-light" style="width: 240px">{{ key }}</th>
|
||||||
|
<td>{{ formatValue(value) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="table-responsive mb-3">
|
||||||
|
<div class="fw-semibold mb-2">Params</div>
|
||||||
|
<table class="table table-sm align-middle mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="([key, value]) in Object.entries(paramsObject(selectedCase))" :key="key">
|
||||||
|
<th class="table-light" style="width: 240px">{{ key }}</th>
|
||||||
|
<td>{{ formatValue(value) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="position-relative" style="min-height: 360px;">
|
||||||
|
<div ref="caseChartRef" style="height: 360px; width: 100%;"></div>
|
||||||
|
<div
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Нажмите на точку на верхнем графике, чтобы увидеть расчетный случай.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex flex-wrap gap-2 mt-3">
|
||||||
|
<button
|
||||||
|
v-for="option in fieldOptions"
|
||||||
|
:key="option.value"
|
||||||
|
type="button"
|
||||||
|
class="btn btn-sm"
|
||||||
|
:class="selectedField === option.value ? 'btn-primary' : 'btn-outline-primary'"
|
||||||
|
@click="selectedField = option.value"
|
||||||
|
:disabled="!selectedFieldMap"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="position-relative mt-3" style="min-height: 420px;">
|
||||||
|
<div ref="fieldMapRef" style="height: 420px; width: 100%;"></div>
|
||||||
|
<div
|
||||||
|
v-if="selectedFieldMapLoading"
|
||||||
|
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center bg-white bg-opacity-75"
|
||||||
|
>
|
||||||
|
<div class="text-muted">Загрузка карты полей...</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="selectedFieldMapError"
|
||||||
|
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center text-danger"
|
||||||
|
>
|
||||||
|
{{ selectedFieldMapError }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="!selectedFieldMap"
|
||||||
|
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center text-muted"
|
||||||
|
>
|
||||||
|
Нажмите на точку графика `psi_m`, чтобы показать карту полей.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { analizeApi, recalculateAnalize } from '@/api.ts'
|
||||||
|
import { formatApiError } from '@/api_client.ts'
|
||||||
|
import type { Analize } from '@/models.ts'
|
||||||
|
|
||||||
|
const items = ref<Analize[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const recalculatingId = ref<number | null>(null)
|
||||||
|
|
||||||
|
function normalizeAnalize(item: Analize | Record<string, unknown>): Analize {
|
||||||
|
const record = item as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
ID: Number(record.ID ?? record.id ?? 0),
|
||||||
|
CreatedAt: String(record.CreatedAt ?? record.createdAt ?? ''),
|
||||||
|
UpdatedAt: String(record.UpdatedAt ?? record.updatedAt ?? ''),
|
||||||
|
DeletedAt: (record.DeletedAt ?? record.deletedAt ?? null) as Analize['DeletedAt'],
|
||||||
|
Name: String(record.Name ?? record.name ?? ''),
|
||||||
|
CaseID: Number(record.CaseID ?? record.case_id ?? 0),
|
||||||
|
CaseName: String(record.CaseName ?? record.case_name ?? ''),
|
||||||
|
PsiMax: Number(record.PsiMax ?? record.psi_max ?? 0),
|
||||||
|
PsiLMax: Number(record.PsiLMax ?? record.psi_l_max ?? 0),
|
||||||
|
Omega: Number(record.Omega ?? record.omega ?? 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | undefined) {
|
||||||
|
if (!value) return '—'
|
||||||
|
const date = new Date(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await analizeApi.listAll()
|
||||||
|
items.value = response.map((item) => normalizeAnalize(item))
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, 'Не удалось загрузить analize.')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recalculate(item: Analize) {
|
||||||
|
recalculatingId.value = item.ID
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await recalculateAnalize(item.ID)
|
||||||
|
const normalized = normalizeAnalize(updated)
|
||||||
|
items.value = items.value.map((current) => (current.ID === normalized.ID ? normalized : current))
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, 'Не удалось пересчитать analize.')
|
||||||
|
} finally {
|
||||||
|
recalculatingId.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadItems)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="d-flex flex-column gap-3">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">Analize</h1>
|
||||||
|
<p class="text-muted mb-0">Результаты обработки расчетных случаев</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" @click="loadItems" :disabled="loading">
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
<RouterLink class="btn btn-outline-primary" to="/analize/chart">График</RouterLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger mb-0" role="alert">{{ error }}</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>Название</th>
|
||||||
|
<th>Case</th>
|
||||||
|
<th>PsiMax</th>
|
||||||
|
<th>PsiLMax</th>
|
||||||
|
<th>Omega</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
<th class="text-end">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="items.length === 0">
|
||||||
|
<td colspan="8" class="text-center text-muted py-4">Записи не найдены</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="item in items" :key="item.ID">
|
||||||
|
<td class="fw-semibold">{{ item.ID }}</td>
|
||||||
|
<td>{{ item.Name || '—' }}</td>
|
||||||
|
<td>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<RouterLink
|
||||||
|
class="fw-medium text-decoration-none"
|
||||||
|
:to="{ name: 'control-case-detail', params: { id: item.CaseID } }"
|
||||||
|
>
|
||||||
|
{{ item.CaseName || '—' }}
|
||||||
|
</RouterLink>
|
||||||
|
<small class="text-muted">#{{ item.CaseID }}</small>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>{{ item.PsiMax }}</td>
|
||||||
|
<td>{{ item.PsiLMax }}</td>
|
||||||
|
<td>{{ item.Omega }}</td>
|
||||||
|
<td>{{ formatDate(item.CreatedAt) }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-primary"
|
||||||
|
type="button"
|
||||||
|
@click="recalculate(item)"
|
||||||
|
:disabled="recalculatingId === item.ID"
|
||||||
|
>
|
||||||
|
{{ recalculatingId === item.ID ? 'Пересчет...' : 'Пересчитать' }}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
import * as echarts from 'echarts'
|
||||||
|
import { getControlCaseChartData, type CsvChartResponse } from '@/api.ts'
|
||||||
|
import { formatApiError } from '@/api_client.ts'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const containerRef = ref<HTMLDivElement | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const data = ref<CsvChartResponse | null>(null)
|
||||||
|
let chart: echarts.ECharts | null = null
|
||||||
|
|
||||||
|
const id = computed(() => Number(route.params.id))
|
||||||
|
|
||||||
|
function buildOption(payload: CsvChartResponse): echarts.EChartsOption {
|
||||||
|
const [xKey, ...seriesKeys] = payload.columns
|
||||||
|
const rows = payload.rows
|
||||||
|
|
||||||
|
const xValues = rows.map((row) => row[0] ?? '')
|
||||||
|
const series = seriesKeys.map((name, index) => ({
|
||||||
|
name,
|
||||||
|
type: 'line' as const,
|
||||||
|
showSymbol: false,
|
||||||
|
smooth: true,
|
||||||
|
data: rows.map((row) => row[index + 1] ?? null),
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
tooltip: { trigger: 'axis' },
|
||||||
|
legend: { top: 0 },
|
||||||
|
grid: { left: 48, right: 24, top: 48, bottom: 48, containLabel: true },
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
name: xKey ?? 'x',
|
||||||
|
data: xValues,
|
||||||
|
},
|
||||||
|
yAxis: { type: 'value' },
|
||||||
|
series,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderChart() {
|
||||||
|
if (!chart || !data.value) return
|
||||||
|
chart.setOption(buildOption(data.value), true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
if (!Number.isFinite(id.value)) {
|
||||||
|
error.value = 'Некорректный ID'
|
||||||
|
data.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
data.value = await getControlCaseChartData(id.value)
|
||||||
|
renderChart()
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, 'Не удалось загрузить данные графика.')
|
||||||
|
data.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
chart?.resize()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (containerRef.value) {
|
||||||
|
chart = echarts.init(containerRef.value)
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
}
|
||||||
|
await loadData()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(id, loadData)
|
||||||
|
watch(data, renderChart)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
chart?.dispose()
|
||||||
|
chart = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="d-flex flex-column gap-3">
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-2 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<RouterLink class="link-secondary text-decoration-none" :to="{ name: 'control-case-detail', params: { id } }">
|
||||||
|
← Назад к кейсу
|
||||||
|
</RouterLink>
|
||||||
|
<h1 class="h3 mt-2 mb-0">График control case {{ id }}</h1>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" @click="loadData" :disabled="loading">
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger mb-0" role="alert">{{ error }}</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="position-relative" style="min-height: 560px;">
|
||||||
|
<div ref="containerRef" style="height: 560px; width: 100%;"></div>
|
||||||
|
<div
|
||||||
|
v-if="loading"
|
||||||
|
class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-center justify-content-center bg-white bg-opacity-75"
|
||||||
|
>
|
||||||
|
<div class="text-muted">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
|
import { controlCaseApi, recalculateControlCaseAnalysis } from '@/api.ts'
|
||||||
|
import { formatApiError } from '@/api_client.ts'
|
||||||
|
import type { ControlCase } from '@/models.ts'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const item = ref<ControlCase | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const recalculating = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const statusMessage = ref('')
|
||||||
|
|
||||||
|
const id = computed(() => Number(route.params.id))
|
||||||
|
|
||||||
|
function formatValue(value: unknown) {
|
||||||
|
if (value == null || value === '') return '—'
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
if ('Time' in (value as Record<string, unknown>) && 'Valid' in (value as Record<string, unknown>)) {
|
||||||
|
const deletedAt = value as { Time?: string; Valid?: boolean }
|
||||||
|
return deletedAt.Valid ? (deletedAt.Time ?? '—') : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | undefined) {
|
||||||
|
if (!value) return '—'
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadItem() {
|
||||||
|
if (!Number.isFinite(id.value)) {
|
||||||
|
error.value = 'Некорректный ID'
|
||||||
|
item.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
item.value = await controlCaseApi.retrieve(id.value)
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, 'Не удалось загрузить control_case.')
|
||||||
|
item.value = null
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recalculateAnalysis() {
|
||||||
|
if (!Number.isFinite(id.value)) return
|
||||||
|
|
||||||
|
recalculating.value = true
|
||||||
|
error.value = ''
|
||||||
|
statusMessage.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await recalculateControlCaseAnalysis(id.value)
|
||||||
|
statusMessage.value = 'Анализ пересчитан.'
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, 'Не удалось пересчитать анализ.')
|
||||||
|
} finally {
|
||||||
|
recalculating.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadItem)
|
||||||
|
watch(id, loadItem)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="d-flex flex-column gap-3">
|
||||||
|
<div class="d-flex align-items-center justify-content-between gap-2 flex-wrap">
|
||||||
|
<div>
|
||||||
|
<RouterLink class="link-secondary text-decoration-none" to="/control-cases">
|
||||||
|
← Назад к списку
|
||||||
|
</RouterLink>
|
||||||
|
<h1 class="h3 mt-2 mb-0">Control case {{ id }}</h1>
|
||||||
|
</div>
|
||||||
|
<RouterLink
|
||||||
|
v-if="item"
|
||||||
|
class="btn btn-outline-primary"
|
||||||
|
:to="{ name: 'control-case-chart', params: { id } }"
|
||||||
|
>
|
||||||
|
График
|
||||||
|
</RouterLink>
|
||||||
|
<button
|
||||||
|
v-if="item"
|
||||||
|
class="btn btn-outline-success"
|
||||||
|
type="button"
|
||||||
|
@click="recalculateAnalysis"
|
||||||
|
:disabled="recalculating"
|
||||||
|
>
|
||||||
|
{{ recalculating ? 'Пересчет...' : 'Пересчитать анализ' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger mb-0" role="alert">{{ error }}</div>
|
||||||
|
<div v-if="statusMessage" class="alert alert-success mb-0" role="alert">{{ statusMessage }}</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="card shadow-sm">
|
||||||
|
<div class="card-body text-center text-muted py-5">Загрузка...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else-if="item">
|
||||||
|
<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">{{ item.Name || '—' }}</div>
|
||||||
|
</div>
|
||||||
|
<span class="badge text-bg-secondary fs-6">{{ item.Status || '—' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-3">ID</dt>
|
||||||
|
<dd class="col-sm-9">{{ item.ID }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Params ID</dt>
|
||||||
|
<dd class="col-sm-9">{{ item.ParamsID }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Создан</dt>
|
||||||
|
<dd class="col-sm-9">{{ formatDate(item.CreatedAt) }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Обновлён</dt>
|
||||||
|
<dd class="col-sm-9">{{ formatDate(item.UpdatedAt) }}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-3">Удалён</dt>
|
||||||
|
<dd class="col-sm-9">{{ formatValue(item.DeletedAt) }}</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-white fw-semibold">Params</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm mb-0 align-middle">
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(value, key) in item.Params" :key="key">
|
||||||
|
<th class="table-light" style="width: 240px">{{ key }}</th>
|
||||||
|
<td>{{ formatValue(value) }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-else class="alert alert-warning mb-0" role="alert">Запись не найдена</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { RouterLink } from 'vue-router'
|
||||||
|
import { controlCaseApi } from '@/api.ts'
|
||||||
|
import { formatApiError, isPaginatedResponse } from '@/api_client.ts'
|
||||||
|
import type { ControlCase } from '@/models.ts'
|
||||||
|
|
||||||
|
const items = ref<ControlCase[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
async function goToPage(nextPage: number) {
|
||||||
|
page.value = Math.min(Math.max(1, nextPage), totalPages.value)
|
||||||
|
await loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(value: string | undefined) {
|
||||||
|
if (!value) return '—'
|
||||||
|
|
||||||
|
const date = new Date(value)
|
||||||
|
return Number.isNaN(date.getTime()) ? value : date.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await controlCaseApi.list({ page: page.value })
|
||||||
|
if (isPaginatedResponse(response)) {
|
||||||
|
items.value = response.results
|
||||||
|
totalCount.value = response.count
|
||||||
|
if (serverPageSize.value === 0 && response.results.length > 0) {
|
||||||
|
serverPageSize.value = response.results.length
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items.value = response
|
||||||
|
totalCount.value = response.length
|
||||||
|
if (serverPageSize.value === 0 && response.length > 0) {
|
||||||
|
serverPageSize.value = response.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, 'Не удалось загрузить control_case.')
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadItems)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="d-flex flex-column gap-3">
|
||||||
|
<div class="d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">Control cases</h1>
|
||||||
|
<p class="text-muted mb-0">Всего записей: {{ total }}</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-secondary" type="button" @click="loadItems" :disabled="loading">
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger mb-0" role="alert">{{ error }}</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>Название</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
<th>Params ID</th>
|
||||||
|
<th>Создан</th>
|
||||||
|
<th class="text-end">Действия</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-if="items.length === 0">
|
||||||
|
<td colspan="6" class="text-center text-muted py-4">Записи не найдены</td>
|
||||||
|
</tr>
|
||||||
|
<tr v-for="item in items" :key="item.ID">
|
||||||
|
<td class="fw-semibold">{{ item.ID }}</td>
|
||||||
|
<td>{{ item.Name || '—' }}</td>
|
||||||
|
<td><span class="badge text-bg-secondary">{{ item.Status || '—' }}</span></td>
|
||||||
|
<td>{{ item.ParamsID }}</td>
|
||||||
|
<td>{{ formatDate(item.CreatedAt) }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<RouterLink class="btn btn-sm btn-primary" :to="{ name: 'control-case-detail', params: { id: item.ID } }">
|
||||||
|
Открыть
|
||||||
|
</RouterLink>
|
||||||
|
</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>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,646 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from "vue";
|
||||||
|
import { useRouter } from "vue-router";
|
||||||
|
import { paramsApi } from "@/api.ts";
|
||||||
|
import { formatApiError, isPaginatedResponse } from "@/api_client.ts";
|
||||||
|
import { launchControlCase } from "@/api.ts";
|
||||||
|
import type { Params } from "@/models.ts";
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const items = ref<Params[]>([]);
|
||||||
|
const loading = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const launchingId = ref<number | null>(null);
|
||||||
|
const error = ref("");
|
||||||
|
const selectedId = ref<number | null>(null);
|
||||||
|
const page = ref(1);
|
||||||
|
const totalCount = ref(0);
|
||||||
|
const serverPageSize = ref(0);
|
||||||
|
|
||||||
|
const form = reactive<Params>({
|
||||||
|
id: 0,
|
||||||
|
created_at: "",
|
||||||
|
updated_at: "",
|
||||||
|
deleted_at: null,
|
||||||
|
rel: 0,
|
||||||
|
rel_c: 0,
|
||||||
|
le: 0,
|
||||||
|
pr: 0,
|
||||||
|
pe: 0,
|
||||||
|
ma: 0,
|
||||||
|
initial_condition: "",
|
||||||
|
time: 0,
|
||||||
|
folder_path: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const createForm = reactive({
|
||||||
|
rel: 0,
|
||||||
|
rel_c: 0,
|
||||||
|
le: 0,
|
||||||
|
pr: 0,
|
||||||
|
pe: 0,
|
||||||
|
ma: 0,
|
||||||
|
initial_condition: "",
|
||||||
|
time: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const batchForm = reactive({
|
||||||
|
fixed: {
|
||||||
|
rel: 0,
|
||||||
|
rel_c: 0,
|
||||||
|
le: 0,
|
||||||
|
pr: 0,
|
||||||
|
pe: 0,
|
||||||
|
ma: 0,
|
||||||
|
initial_condition: "",
|
||||||
|
time: 0,
|
||||||
|
},
|
||||||
|
variable: "rel",
|
||||||
|
start: 0,
|
||||||
|
end: 0,
|
||||||
|
step: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const batchRunning = ref(false);
|
||||||
|
const batchMessage = ref("");
|
||||||
|
|
||||||
|
const selectedItem = computed(
|
||||||
|
() => items.value.find((item) => item.id === selectedId.value) ?? null,
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
selectedId.value = item.id;
|
||||||
|
Object.assign(form, structuredClone(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function goToPage(nextPage: number) {
|
||||||
|
page.value = Math.min(Math.max(1, nextPage), totalPages.value);
|
||||||
|
await loadItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetCreateForm() {
|
||||||
|
createForm.rel = 0;
|
||||||
|
createForm.rel_c = 0;
|
||||||
|
createForm.le = 0;
|
||||||
|
createForm.pr = 0;
|
||||||
|
createForm.pe = 0;
|
||||||
|
createForm.ma = 0;
|
||||||
|
createForm.initial_condition = "";
|
||||||
|
createForm.time = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBatchForm() {
|
||||||
|
batchForm.fixed.rel = 0;
|
||||||
|
batchForm.fixed.rel_c = 0;
|
||||||
|
batchForm.fixed.le = 0;
|
||||||
|
batchForm.fixed.pr = 0;
|
||||||
|
batchForm.fixed.pe = 0;
|
||||||
|
batchForm.fixed.ma = 0;
|
||||||
|
batchForm.fixed.initial_condition = "";
|
||||||
|
batchForm.fixed.time = 0;
|
||||||
|
batchForm.variable = "rel";
|
||||||
|
batchForm.start = 0;
|
||||||
|
batchForm.end = 0;
|
||||||
|
batchForm.step = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRange(start: number, end: number, step: number) {
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end) || !Number.isFinite(step) || step === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const values: number[] = [];
|
||||||
|
const direction = start <= end ? 1 : -1;
|
||||||
|
const normalizedStep = Math.abs(step) * direction;
|
||||||
|
const epsilon = Math.abs(normalizedStep) / 100000;
|
||||||
|
|
||||||
|
for (let current = start; direction > 0 ? current <= end + epsilon : current >= end - epsilon; current += normalizedStep) {
|
||||||
|
values.push(Number(current.toFixed(10)));
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadItems() {
|
||||||
|
loading.value = true;
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await paramsApi.list({ page: page.value });
|
||||||
|
if (isPaginatedResponse(response)) {
|
||||||
|
items.value = response.results;
|
||||||
|
totalCount.value = response.count;
|
||||||
|
if (serverPageSize.value === 0 && response.results.length > 0) {
|
||||||
|
serverPageSize.value = response.results.length;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items.value = response;
|
||||||
|
totalCount.value = response.length;
|
||||||
|
if (serverPageSize.value === 0 && response.length > 0) {
|
||||||
|
serverPageSize.value = response.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!selectedId.value) {
|
||||||
|
const first = items.value[0];
|
||||||
|
if (first) selectItem(first);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, "Не удалось загрузить params.");
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveItem() {
|
||||||
|
if (!selectedId.value) return;
|
||||||
|
|
||||||
|
saving.value = true;
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await paramsApi.update(selectedId.value, {
|
||||||
|
...form,
|
||||||
|
rel: Number(form.rel),
|
||||||
|
rel_c: Number(form.rel_c),
|
||||||
|
le: Number(form.le),
|
||||||
|
pr: Number(form.pr),
|
||||||
|
pe: Number(form.pe),
|
||||||
|
time: Number(form.time),
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadItems();
|
||||||
|
selectItem(updated);
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, "Не удалось сохранить params.");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createItem() {
|
||||||
|
saving.value = true;
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const created = await paramsApi.create({
|
||||||
|
rel: Number(createForm.rel),
|
||||||
|
rel_c: Number(createForm.rel_c),
|
||||||
|
le: Number(createForm.le),
|
||||||
|
pr: Number(createForm.pr),
|
||||||
|
pe: Number(createForm.pe),
|
||||||
|
ma: Number(createForm.ma),
|
||||||
|
initial_condition: createForm.initial_condition,
|
||||||
|
time: Number(createForm.time),
|
||||||
|
});
|
||||||
|
|
||||||
|
selectItem(created);
|
||||||
|
await loadItems();
|
||||||
|
resetCreateForm();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, "Не удалось создать params.");
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createBatch() {
|
||||||
|
batchRunning.value = true;
|
||||||
|
batchMessage.value = "";
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const values = buildRange(batchForm.start, batchForm.end, batchForm.step);
|
||||||
|
if (values.length === 0) {
|
||||||
|
throw new Error("Некорректный диапазон или шаг.");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const value of values) {
|
||||||
|
const payload = {
|
||||||
|
rel: Number(batchForm.fixed.rel),
|
||||||
|
rel_c: Number(batchForm.fixed.rel_c),
|
||||||
|
le: Number(batchForm.fixed.le),
|
||||||
|
pr: Number(batchForm.fixed.pr),
|
||||||
|
pe: Number(batchForm.fixed.pe),
|
||||||
|
ma: Number(batchForm.fixed.ma),
|
||||||
|
initial_condition: batchForm.fixed.initial_condition,
|
||||||
|
time: Number(batchForm.fixed.time),
|
||||||
|
} as Record<string, string | number>;
|
||||||
|
|
||||||
|
payload[batchForm.variable] = value;
|
||||||
|
|
||||||
|
const created = await paramsApi.create(payload as never);
|
||||||
|
await launchControlCase({
|
||||||
|
params_id: created.id,
|
||||||
|
name: `${batchForm.variable}-${value}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
batchMessage.value = `Создано ${values.length} расчетных случаев.`;
|
||||||
|
await loadItems();
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, "Не удалось создать пакет расчетов.");
|
||||||
|
} finally {
|
||||||
|
batchRunning.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchParams(item: Params) {
|
||||||
|
launchingId.value = item.id;
|
||||||
|
error.value = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const controlCase = await launchControlCase({
|
||||||
|
params_id: item.id,
|
||||||
|
name: `case-${item.id}`,
|
||||||
|
});
|
||||||
|
await router.push({ name: "control-case-detail", params: { id: controlCase.ID } });
|
||||||
|
} catch (err) {
|
||||||
|
error.value = formatApiError(err, "Не удалось запустить расчет.");
|
||||||
|
} finally {
|
||||||
|
launchingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(loadItems);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="d-flex flex-column gap-3">
|
||||||
|
<div
|
||||||
|
class="d-flex flex-wrap justify-content-between align-items-center gap-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<h1 class="h3 mb-1">Params</h1>
|
||||||
|
<p class="text-muted mb-0">Таблица параметров с редактированием</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="btn btn-outline-secondary"
|
||||||
|
type="button"
|
||||||
|
@click="loadItems"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="error" class="alert alert-danger mb-0" role="alert">
|
||||||
|
{{ error }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="batchMessage" class="alert alert-success mb-0" role="alert">
|
||||||
|
{{ batchMessage }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-white fw-semibold">Быстрый расчет</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<form class="row g-3" @submit.prevent="createBatch">
|
||||||
|
<div class="col-12 col-lg-4">
|
||||||
|
<label class="form-label">Переменный параметр</label>
|
||||||
|
<select v-model="batchForm.variable" class="form-select">
|
||||||
|
<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">
|
||||||
|
<div class="text-muted small mb-2">Фиксированные параметры</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<label class="form-label">rel</label>
|
||||||
|
<input v-model.number="batchForm.fixed.rel" type="number" step="any" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<label class="form-label">rel_c</label>
|
||||||
|
<input v-model.number="batchForm.fixed.rel_c" type="number" step="any" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<label class="form-label">le</label>
|
||||||
|
<input v-model.number="batchForm.fixed.le" type="number" step="any" class="form-control" />
|
||||||
|
</div>
|
||||||
|
<div class="col-6 col-lg-2">
|
||||||
|
<label class="form-label">pr</label>
|
||||||
|
<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">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" @click="resetBatchForm" :disabled="batchRunning">
|
||||||
|
Сбросить
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary" type="submit" :disabled="batchRunning">
|
||||||
|
{{ batchRunning ? "Создание..." : "Создать серию" }}
|
||||||
|
</button>
|
||||||
|
</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>
|
||||||
|
</template>
|
||||||
+162
-1
@@ -1,7 +1,17 @@
|
|||||||
package control_case
|
package control_case
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"control/analize"
|
||||||
"github.com/che4web/go4rest"
|
"github.com/che4web/go4rest"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -11,8 +21,11 @@ type ControlCaseController struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewControlCaseController(db *gorm.DB) *ControlCaseController {
|
func NewControlCaseController(db *gorm.DB) *ControlCaseController {
|
||||||
|
viewSet := go4rest.NewViewSet[ControlCase](db)
|
||||||
|
viewSet.PreloadField = []string{"Params"}
|
||||||
|
|
||||||
return &ControlCaseController{
|
return &ControlCaseController{
|
||||||
ViewSet: go4rest.NewViewSet[ControlCase](db),
|
ViewSet: viewSet,
|
||||||
db: db,
|
db: db,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -28,3 +41,151 @@ func NewParamsController(db *gorm.DB) *ParamsController {
|
|||||||
db: db,
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
+32
-25
@@ -3,10 +3,10 @@ package control_case
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/BurntSushi/toml"
|
"github.com/BurntSushi/toml"
|
||||||
"github.com/otiai10/copy"
|
"github.com/otiai10/copy"
|
||||||
@@ -22,16 +22,19 @@ type ControlCase struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Params struct {
|
type Params struct {
|
||||||
gorm.Model
|
ID uint `gorm:"primaryKey" json:"id" toml:"-" csv:"id"`
|
||||||
ID uint `toml:"-" csv:"id"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
Rel float64 `toml:"rel" csv:"rel"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
RelC float64 `toml:"rel_c" csv:"rel_c"`
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
|
||||||
Le float64 `toml:"le" csv:"le"`
|
Rel float64 `json:"rel" toml:"rel" csv:"rel"`
|
||||||
Pr float64 `toml:"pr" csv:"pr"`
|
RelC float64 `json:"rel_c" toml:"rel_c" csv:"rel_c"`
|
||||||
Pe float64 `toml:"pe" csv:"pe"`
|
Le float64 `json:"le" toml:"le" csv:"le"`
|
||||||
InitialCondition string `toml:"-" csv:"initial_condition"`
|
Pr float64 `json:"pr" toml:"pr" csv:"pr"`
|
||||||
Time float64 `toml:"time" csv:"time"`
|
Pe float64 `json:"pe" toml:"pe" csv:"pe"`
|
||||||
FolderPath string `toml:"-" csv:"-"`
|
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 {
|
func (p *Params) ToTOML(filename string) error {
|
||||||
@@ -45,37 +48,40 @@ func (p *Params) ToTOML(filename string) error {
|
|||||||
return encoder.Encode(p)
|
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) {
|
func (p *Params) Exist() (bool, error) {
|
||||||
folderPath := filepath.Join("./", fmt.Sprintf("%d", p.ID))
|
folderPath := filepath.Join("./", fmt.Sprintf("%d", p.ID))
|
||||||
return exists(folderPath)
|
return exists(folderPath)
|
||||||
}
|
}
|
||||||
func (p *Params) CreateFolder() {
|
func (p *Params) CreateFolder() error {
|
||||||
BIN_PATH := "./bio-convect"
|
BIN_PATH := "./bio-convect"
|
||||||
folderPath := filepath.Join("./", fmt.Sprintf("%d", p.ID))
|
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)
|
fmt.Printf("folder: %s\n", folderPath)
|
||||||
execPath := filepath.Join("./", folderPath, BIN_PATH)
|
execPath := filepath.Join("./", folderPath, BIN_PATH)
|
||||||
err := copy.Copy(BIN_PATH, execPath)
|
err := copy.Copy(BIN_PATH, execPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
return err
|
||||||
}
|
}
|
||||||
if p.InitialCondition != "E" {
|
if err := os.Chmod(execPath, 0o755); err != nil {
|
||||||
startPath := filepath.Join("./", folderPath, "storag.h5")
|
return err
|
||||||
|
}
|
||||||
|
if p.InitialCondition != "" && p.InitialCondition != "E" {
|
||||||
|
startPath := filepath.Join("./", folderPath, "storage.h5")
|
||||||
err = copy.Copy(p.InitialCondition, startPath)
|
err = copy.Copy(p.InitialCondition, startPath)
|
||||||
if err != nil {
|
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
|
p.FolderPath = folderPath
|
||||||
|
return nil
|
||||||
|
|
||||||
}
|
}
|
||||||
func (p *Params) Run(ctx context.Context) {
|
func (p *Params) Run(ctx context.Context) error {
|
||||||
BIN_PATH := "./bio-convect"
|
BIN_PATH := "./bio-convect"
|
||||||
cmd := exec.CommandContext(ctx, BIN_PATH)
|
cmd := exec.CommandContext(ctx, BIN_PATH)
|
||||||
cmd.Dir = p.FolderPath
|
cmd.Dir = p.FolderPath
|
||||||
@@ -86,7 +92,8 @@ func (p *Params) Run(ctx context.Context) {
|
|||||||
err := cmd.Run()
|
err := cmd.Run()
|
||||||
fmt.Printf("afrer run")
|
fmt.Printf("afrer run")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
return err
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ func RegisterApp(r *gin.Engine, db *gorm.DB) {
|
|||||||
params := NewParamsController(db)
|
params := NewParamsController(db)
|
||||||
go4rest.RegisterCRUDRoutes(r, "control_case", controller)
|
go4rest.RegisterCRUDRoutes(r, "control_case", controller)
|
||||||
go4rest.RegisterCRUDRoutes(r, "params", params)
|
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
|
numJobs := 1
|
||||||
//jobs := make(chan Params, numJobs)
|
//jobs := make(chan Params, numJobs)
|
||||||
results := make(chan int, numJobs)
|
results := make(chan int, numJobs)
|
||||||
|
|||||||
+41
-17
@@ -5,11 +5,12 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"control/analize"
|
||||||
"github.com/gocarina/gocsv"
|
"github.com/gocarina/gocsv"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -25,11 +26,40 @@ func exists(path string) (bool, error) {
|
|||||||
return false, err
|
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 {
|
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)
|
||||||
j.CreateFolder()
|
p := j.Params
|
||||||
j.Run(ctx)
|
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) // Имитация длительной задачи
|
time.Sleep(time.Second) // Имитация длительной задачи
|
||||||
fmt.Printf("Worker %d finished job %d \n", id, j.ID)
|
fmt.Printf("Worker %d finished job %d \n", id, j.ID)
|
||||||
// results <- j * 2
|
// results <- j * 2
|
||||||
@@ -39,13 +69,15 @@ func worker(id int, jobs <-chan Params, ctx context.Context) {
|
|||||||
func readCSVWithGocsv() []Params {
|
func readCSVWithGocsv() []Params {
|
||||||
file, err := os.Open("db.csv")
|
file, err := os.Open("db.csv")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
fmt.Printf("failed to open csv: %v\n", err)
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
var params []Params
|
var params []Params
|
||||||
if err := gocsv.UnmarshalFile(file, ¶ms); err != nil {
|
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 {
|
for _, person := range params {
|
||||||
@@ -73,7 +105,7 @@ func RunControllWorker(db *gorm.DB, results chan int) {
|
|||||||
cancel()
|
cancel()
|
||||||
}() // Cancel if main exits early
|
}() // Cancel if main exits early
|
||||||
|
|
||||||
jobs := make(chan Params, numJobs)
|
jobs := make(chan ControlCase, numJobs)
|
||||||
//results := make(chan int, numJobs)
|
//results := make(chan int, numJobs)
|
||||||
|
|
||||||
// Запуск воркеров
|
// Запуск воркеров
|
||||||
@@ -82,7 +114,7 @@ func RunControllWorker(db *gorm.DB, results chan int) {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func(w int) {
|
go func(w int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
worker(w, jobs, ctx)
|
worker(w, jobs, ctx, db)
|
||||||
}(w)
|
}(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,15 +123,7 @@ func RunControllWorker(db *gorm.DB, results chan int) {
|
|||||||
cases := readFromDb(db)
|
cases := readFromDb(db)
|
||||||
// Отправка задач
|
// Отправка задач
|
||||||
for _, c := range cases {
|
for _, c := range cases {
|
||||||
p := c.Params
|
jobs <- c
|
||||||
e, _ := p.Exist()
|
|
||||||
if e {
|
|
||||||
c.Status = "R"
|
|
||||||
db.Save((&c))
|
|
||||||
} else {
|
|
||||||
p.CreateFolder()
|
|
||||||
jobs <- p
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
time.Sleep(10 * time.Second)
|
time.Sleep(10 * time.Second)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package main
|
|||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"control/analize"
|
||||||
"control/control_case"
|
"control/control_case"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/glebarez/sqlite"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -19,6 +21,7 @@ func main() {
|
|||||||
_ = db
|
_ = db
|
||||||
|
|
||||||
control_case.RegisterApp(r, db)
|
control_case.RegisterApp(r, db)
|
||||||
|
analize.RegisterApp(r, db)
|
||||||
// numJobs := 10
|
// numJobs := 10
|
||||||
//jobs := make(chan control_case.Params, numJobs)
|
//jobs := make(chan control_case.Params, numJobs)
|
||||||
//results := make(chan int, numJobs)
|
//results := make(chan int, numJobs)
|
||||||
|
|||||||
Reference in New Issue
Block a user