This commit is contained in:
che
2026-07-13 11:17:57 +05:00
parent e3490e5d2e
commit 6fff682f74
18 changed files with 411 additions and 453 deletions
+9 -2
View File
@@ -1,9 +1,16 @@
package analize
import "gorm.io/gorm"
import (
"time"
"gorm.io/gorm"
)
type Analize struct {
gorm.Model
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
Name string `json:"name"`
CaseID uint `json:"case_id"`
CaseName string `json:"case_name"`
+8 -158
View File
@@ -1,17 +1,12 @@
package analize
import (
"encoding/csv"
"errors"
"fmt"
"math"
"os"
"strconv"
"strings"
"time"
"control/control_case"
"gonum.org/v1/gonum/dsp/fourier"
"control/series"
"gorm.io/gorm"
)
@@ -40,81 +35,27 @@ func RunAnalyzeWorker(db *gorm.DB) {
}
func AnalyzeCSV(db *gorm.DB, caseID uint, caseName, csvPath string) (*Analize, error) {
file, err := os.Open(csvPath)
data, err := series.ReadCSV(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 {
if len(data.Rows) == 0 {
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")
columns, err := data.NumericColumns("t", "psi_m", "psi_l")
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)
}
times, psiM, psiL := columns[0], columns[1], columns[2]
if len(psiL) == 0 {
return nil, fmt.Errorf("no numeric rows found in csv")
}
psiMax := maxFloat(psiM)
psiLMax := maxFloat(psiL)
omega := mainAngularFrequency(times, psiL)
psiMax := series.MaxLastHalf(psiM)
psiLMax := series.MaxLastHalf(psiL)
omega := series.MainAngularFrequency(times, psiL)
result := &Analize{
Name: fmt.Sprintf("analysis-%d", caseID),
@@ -143,94 +84,3 @@ func AnalyzeCSV(db *gorm.DB, caseID uint, caseName, csvPath string) (*Analize, e
}
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[len(values)-1]
for _, v := range values[len(values):] {
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)
}
+37 -31
View File
@@ -3,8 +3,6 @@ import type { Params, ControlCase, Analize, InitialCondition } from "@/models.ts
export const paramsApi = createModelApi<Params>("params");
export const controlCaseApi = createModelApi<ControlCase>("control_case");
export const analizeApi = createModelApi<Analize>("analize");
export const initialConditionApi = createModelApi<InitialCondition>("initial_condition");
export interface CsvChartResponse {
@@ -37,21 +35,29 @@ export interface InitialConditionResponse {
initial_condition: InitialCondition;
}
export async function getControlCaseChartData(id: number) {
export interface LaunchControlCasePayload {
params_id: number;
name?: string;
}
export const controlCaseApi = {
...createModelApi<ControlCase>("control_case"),
async chartData(id: number) {
const response = await apiClient.get<CsvChartResponse>(
`/control_case/${id}/chart-data`,
);
return response.data;
}
},
export async function getControlCasePsiSpectrum(id: number) {
async psiSpectrum(id: number) {
const response = await apiClient.get<PsiSpectrumResponse>(
`/control_case/${id}/psi-spectrum`,
);
return response.data;
}
},
export async function getControlCaseFieldMap(id: number, time: number) {
async fieldMap(id: number, time: number) {
const response = await apiClient.get<FieldMapResponse>(
`/control_case/${id}/field-map`,
{
@@ -59,39 +65,23 @@ export async function getControlCaseFieldMap(id: number, time: number) {
},
);
return response.data;
}
},
export async function createControlCaseInitialCondition(id: number) {
async createInitialCondition(id: number) {
const response = await apiClient.post<InitialConditionResponse>(
`/control_case/${id}/initial-condition`,
);
return response.data;
}
},
export interface LaunchControlCasePayload {
params_id: number;
name?: string;
}
export async function launchControlCase(payload: LaunchControlCasePayload) {
async launch(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;
@@ -123,9 +113,25 @@ export interface AnalizeSeriesFilters {
group_parameter?: string;
}
export async function getAnalizeSeries(parameter: string, filters?: AnalizeSeriesFilters) {
export const analizeApi = {
...createModelApi<Analize>("analize"),
async recalculate(id: number) {
const response = await apiClient.post<Analize>(`/analize/${id}/recalculate`);
return response.data;
},
async recalculateControlCase(id: number) {
const response = await apiClient.post<Analize>(
`/control_case/${id}/recalculate-analysis`,
);
return response.data;
},
async series(parameter: string, filters?: AnalizeSeriesFilters) {
const response = await apiClient.get<AnalizeSeriesResponse>("/analize/series", {
params: { parameter, ...(filters ?? {}) },
});
return response.data;
}
},
};
-1
View File
@@ -2,7 +2,6 @@ import axios from "axios";
export interface BaseEntity {
id?: number;
ID?: number;
}
export interface ListParams {
@@ -53,8 +53,8 @@ defineEmits<{
<label class="form-label">initial_condition</label>
<select v-model="form.initial_condition_id" class="form-select">
<option :value="null">Не задано</option>
<option v-for="condition in initialConditions" :key="condition.ID" :value="condition.ID">
{{ condition.name || `#${condition.ID}` }} {{ condition.file_path }}
<option v-for="condition in initialConditions" :key="condition.id" :value="condition.id">
{{ condition.name || `#${condition.id}` }} {{ condition.file_path }}
</option>
</select>
</div>
@@ -97,8 +97,8 @@ defineEmits<{
<td>
<select v-model="batchForm.fixed.initial_condition_id" class="form-select">
<option :value="null">Не задано</option>
<option v-for="condition in initialConditions" :key="condition.ID" :value="condition.ID">
{{ condition.name || `#${condition.ID}` }} {{ condition.file_path }}
<option v-for="condition in initialConditions" :key="condition.id" :value="condition.id">
{{ condition.name || `#${condition.id}` }} {{ condition.file_path }}
</option>
</select>
</td>
@@ -80,8 +80,8 @@ defineEmits<{
<td>
<select v-model="createForm.initial_condition_id" class="form-select form-select-sm">
<option :value="null"></option>
<option v-for="condition in initialConditions" :key="condition.ID" :value="condition.ID">
{{ condition.name || `#${condition.ID}` }}
<option v-for="condition in initialConditions" :key="condition.id" :value="condition.id">
{{ condition.name || `#${condition.id}` }}
</option>
</select>
</td>
+19 -19
View File
@@ -21,34 +21,34 @@ export interface Params {
}
export interface InitialCondition {
ID: number;
CreatedAt: string;
UpdatedAt: string;
DeletedAt: GormDeletedAt | null;
id: number;
created_at: string;
updated_at: string;
deleted_at: GormDeletedAt | null;
name: string;
file_path: string;
}
export interface ControlCase {
ID: number;
CreatedAt: string;
UpdatedAt: string;
DeletedAt: GormDeletedAt | null;
id: number;
created_at: string;
updated_at: string;
deleted_at: GormDeletedAt | null;
name: string;
paramsID: number;
params_id: number;
params: Params;
status: string;
}
export interface Analize {
ID: number;
CreatedAt: string;
UpdatedAt: string;
DeletedAt: GormDeletedAt | null;
Name: string;
CaseID: number;
CaseName: string;
PsiMax: number;
PsiLMax: number;
Omega: number;
id: number;
created_at: string;
updated_at: string;
deleted_at: GormDeletedAt | null;
name: string;
case_id: number;
case_name: string;
psi_max: number;
psi_l_max: number;
omega: number;
}
+6 -6
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import * as echarts from 'echarts'
import { controlCaseApi, getAnalizeSeries, getControlCaseChartData, getControlCaseFieldMap, getControlCasePsiSpectrum } from '@/api.ts'
import { analizeApi, controlCaseApi } from '@/api.ts'
import { formatApiError } from '@/api_client.ts'
import type { ControlCase } from '@/models.ts'
import { controlCaseStatusMeta } from '@/statuses.ts'
@@ -446,7 +446,7 @@ async function loadData() {
error.value = ''
try {
const response = await getAnalizeSeries(selectedParameter.value, {
const response = await analizeApi.series(selectedParameter.value, {
group_parameter: groupParameter.value || undefined,
filter_parameter: filterParameter.value || undefined,
filter_min: filterMin.value === '' ? undefined : Number(filterMin.value),
@@ -498,8 +498,8 @@ async function loadSelectedCase(caseId: number) {
try {
const [caseData, csvData, spectrumData] = await Promise.all([
controlCaseApi.retrieve(caseId),
getControlCaseChartData(caseId),
getControlCasePsiSpectrum(caseId),
controlCaseApi.chartData(caseId),
controlCaseApi.psiSpectrum(caseId),
])
selectedCase.value = caseData
@@ -543,7 +543,7 @@ async function loadFieldMap(time: number) {
selectedFieldMapError.value = ''
try {
selectedFieldMap.value = await getControlCaseFieldMap(selectedCaseId.value, time)
selectedFieldMap.value = await controlCaseApi.fieldMap(selectedCaseId.value, time)
renderFieldMap()
} catch (err) {
selectedFieldMapError.value = formatApiError(err, 'Не удалось загрузить карту полей.')
@@ -711,7 +711,7 @@ onBeforeUnmount(() => {
</div>
<div class="col-12 col-lg-4">
<div class="text-muted small">Params ID</div>
<div class="fw-semibold">{{ selectedCase.paramsID }}</div>
<div class="fw-semibold">{{ selectedCase.params_id }}</div>
</div>
</div>
+26 -26
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { analizeApi, recalculateAnalize } from '@/api.ts'
import { analizeApi } from '@/api.ts'
import { formatApiError, isPaginatedResponse } from '@/api_client.ts'
import type { Analize } from '@/models.ts'
@@ -30,16 +30,16 @@ const pageEnd = computed(() => {
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),
id: Number(record.id ?? 0),
created_at: String(record.created_at ?? ''),
updated_at: String(record.updated_at ?? ''),
deleted_at: (record.deleted_at ?? null) as Analize['deleted_at'],
name: String(record.name ?? ''),
case_id: Number(record.case_id ?? 0),
case_name: String(record.case_name ?? ''),
psi_max: Number(record.psi_max ?? 0),
psi_l_max: Number(record.psi_l_max ?? 0),
omega: Number(record.omega ?? 0),
}
}
@@ -81,13 +81,13 @@ async function loadItems() {
}
async function recalculate(item: Analize) {
recalculatingId.value = item.ID
recalculatingId.value = item.id
error.value = ''
try {
const updated = await recalculateAnalize(item.ID)
const updated = await analizeApi.recalculate(item.id)
const normalized = normalizeAnalize(updated)
items.value = items.value.map((current) => (current.ID === normalized.ID ? normalized : current))
items.value = items.value.map((current) => (current.id === normalized.id ? normalized : current))
} catch (err) {
error.value = formatApiError(err, 'Не удалось пересчитать analize.')
} finally {
@@ -134,32 +134,32 @@ onMounted(loadItems)
<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>
<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 } }"
:to="{ name: 'control-case-detail', params: { id: item.case_id } }"
>
{{ item.CaseName || '—' }}
{{ item.case_name || '—' }}
</RouterLink>
<small class="text-muted">#{{ item.CaseID }}</small>
<small class="text-muted">#{{ item.case_id }}</small>
</div>
</td>
<td>{{ item.PsiMax }}</td>
<td>{{ item.PsiLMax }}</td>
<td>{{ item.Omega }}</td>
<td>{{ formatDate(item.CreatedAt) }}</td>
<td>{{ item.psi_max }}</td>
<td>{{ item.psi_l_max }}</td>
<td>{{ item.omega }}</td>
<td>{{ formatDate(item.created_at) }}</td>
<td class="text-end">
<button
class="btn btn-sm btn-outline-primary"
type="button"
@click="recalculate(item)"
:disabled="recalculatingId === item.ID"
:disabled="recalculatingId === item.id"
>
{{ recalculatingId === item.ID ? 'Пересчет...' : 'Пересчитать' }}
{{ recalculatingId === item.id ? 'Пересчет...' : 'Пересчитать' }}
</button>
</td>
</tr>
@@ -2,7 +2,7 @@
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 { controlCaseApi, type CsvChartResponse } from '@/api.ts'
import { formatApiError } from '@/api_client.ts'
const route = useRoute()
@@ -61,7 +61,7 @@ async function loadData() {
error.value = ''
try {
data.value = await getControlCaseChartData(id.value)
data.value = await controlCaseApi.chartData(id.value)
renderChart()
} catch (err) {
error.value = formatApiError(err, 'Не удалось загрузить данные графика.')
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { controlCaseApi, recalculateControlCaseAnalysis, createControlCaseInitialCondition } from '@/api.ts'
import { analizeApi, controlCaseApi } from '@/api.ts'
import { formatApiError } from '@/api_client.ts'
import type { ControlCase } from '@/models.ts'
import { controlCaseStatusMeta } from '@/statuses.ts'
@@ -66,7 +66,7 @@ async function recalculateAnalysis() {
statusMessage.value = ''
try {
await recalculateControlCaseAnalysis(id.value)
await analizeApi.recalculateControlCase(id.value)
statusMessage.value = 'Анализ пересчитан.'
} catch (err) {
error.value = formatApiError(err, 'Не удалось пересчитать анализ.')
@@ -83,7 +83,7 @@ async function createInitialCondition() {
statusMessage.value = ''
try {
const response = await createControlCaseInitialCondition(id.value)
const response = await controlCaseApi.createInitialCondition(id.value)
statusMessage.value = `Начальные условия созданы: ${response.initial_condition.file_path}`
} catch (err) {
error.value = formatApiError(err, 'Не удалось создать начальные условия.')
@@ -153,19 +153,19 @@ watch(id, loadItem)
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3">ID</dt>
<dd class="col-sm-9">{{ item.ID }}</dd>
<dd class="col-sm-9">{{ item.id }}</dd>
<dt class="col-sm-3">Params ID</dt>
<dd class="col-sm-9">{{ item.paramsID }}</dd>
<dd class="col-sm-9">{{ item.params_id }}</dd>
<dt class="col-sm-3">Создан</dt>
<dd class="col-sm-9">{{ formatDate(item.CreatedAt) }}</dd>
<dd class="col-sm-9">{{ formatDate(item.created_at) }}</dd>
<dt class="col-sm-3">Обновлён</dt>
<dd class="col-sm-9">{{ formatDate(item.UpdatedAt) }}</dd>
<dd class="col-sm-9">{{ formatDate(item.updated_at) }}</dd>
<dt class="col-sm-3">Удалён</dt>
<dd class="col-sm-9">{{ formatValue(item.DeletedAt) }}</dd>
<dd class="col-sm-9">{{ formatValue(item.deleted_at) }}</dd>
</dl>
</div>
</div>
+5 -5
View File
@@ -112,8 +112,8 @@ onMounted(loadItems);
Записи не найдены
</td>
</tr>
<tr v-for="item in items" :key="item.ID">
<td class="fw-semibold">{{ item.ID }}</td>
<tr v-for="item in items" :key="item.id">
<td class="fw-semibold">{{ item.id }}</td>
<td>{{ item.name || "—" }}</td>
<td>
<span
@@ -123,14 +123,14 @@ onMounted(loadItems);
{{ controlCaseStatusMeta(item.status).label }}
</span>
</td>
<td>{{ item.paramsID }}</td>
<td>{{ formatDate(item.CreatedAt) }}</td>
<td>{{ item.params_id }}</td>
<td>{{ formatDate(item.created_at) }}</td>
<td class="text-end">
<RouterLink
class="btn btn-sm btn-primary"
:to="{
name: 'control-case-detail',
params: { id: item.ID },
params: { id: item.id },
}"
>
Открыть
+4 -4
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { initialConditionApi, launchControlCase, paramsApi } from "@/api.ts";
import { controlCaseApi, initialConditionApi, paramsApi } from "@/api.ts";
import { formatApiError, isPaginatedResponse } from "@/api_client.ts";
import ParamsEditCard from "@/components/params/ParamsEditCard.vue";
import ParamsQuickBatchCard from "@/components/params/ParamsQuickBatchCard.vue";
@@ -304,7 +304,7 @@ async function createBatch() {
payload[batchForm.variable] = value;
const created = await paramsApi.create(payload as never);
await launchControlCase({
await controlCaseApi.launch({
params_id: created.id,
name: `${batchForm.variable}-${value}`,
});
@@ -324,11 +324,11 @@ async function launchParams(item: Params) {
error.value = "";
try {
const controlCase = await launchControlCase({
const controlCase = await controlCaseApi.launch({
params_id: item.id,
name: `case-${item.id}`,
});
await router.push({ name: "control-case-detail", params: { id: controlCase.ID } });
await router.push({ name: "control-case-detail", params: { id: controlCase.id } });
} catch (err) {
error.value = formatApiError(err, "Не удалось запустить расчет.");
} finally {
+27 -146
View File
@@ -1,18 +1,18 @@
package control_case
import (
"encoding/csv"
"errors"
"fmt"
"math"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"control/series"
"github.com/che4web/go4rest"
"github.com/gin-gonic/gin"
"gonum.org/v1/gonum/dsp/fourier"
"gorm.io/gorm"
)
@@ -104,10 +104,7 @@ type CSVSeriesResponse struct {
Rows [][]interface{} `json:"rows"`
}
type FFTPoint struct {
Frequency float64 `json:"frequency"`
Amplitude float64 `json:"amplitude"`
}
type FFTPoint = series.SpectrumPoint
type PSISpectrumResponse struct {
TimeStep float64 `json:"time_step"`
@@ -132,27 +129,17 @@ func (c *ControlCaseController) ChartData(ctx *gin.Context) {
}
csvPath := controlCase.FooCSVPath()
file, err := os.Open(csvPath)
if err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "csv file not found"})
data, ok := readCSVResponse(ctx, csvPath)
if !ok {
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 {
if len(data.Columns) == 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:] {
rows := make([][]interface{}, 0, len(data.Rows))
for _, record := range data.Rows {
row := make([]interface{}, 0, len(record))
for _, value := range record {
trimmed := strings.TrimSpace(value)
@@ -165,7 +152,7 @@ func (c *ControlCaseController) ChartData(ctx *gin.Context) {
rows = append(rows, row)
}
ctx.JSON(http.StatusOK, CSVSeriesResponse{Columns: columns, Rows: rows})
ctx.JSON(http.StatusOK, CSVSeriesResponse{Columns: data.Columns, Rows: rows})
}
func (c *ControlCaseController) PSISpectrum(ctx *gin.Context) {
@@ -182,80 +169,23 @@ func (c *ControlCaseController) PSISpectrum(ctx *gin.Context) {
}
csvPath := controlCase.FooCSVPath()
file, err := os.Open(csvPath)
if err != nil {
ctx.JSON(http.StatusNotFound, gin.H{"error": "csv file not found"})
data, ok := readCSVResponse(ctx, csvPath)
if !ok {
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) < 2 {
if len(data.Rows) == 0 {
ctx.JSON(http.StatusOK, PSISpectrumResponse{Points: map[string][]FFTPoint{}})
return
}
headers := make(map[string]int, len(records[0]))
for i, header := range records[0] {
headers[strings.ToLower(strings.TrimSpace(header))] = i
}
getIndex := func(name string) (int, error) {
idx, ok := headers[strings.ToLower(name)]
if !ok {
return -1, fmt.Errorf("missing column %q", name)
}
return idx, nil
}
tIndex, err := getIndex("t")
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
psiMIndex, err := getIndex("psi_m")
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
psiLIndex, err := getIndex("psi_l")
columns, err := data.NumericColumns("t", "psi_m", "psi_l")
if err != nil {
ctx.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
times, psiM, psiL := columns[0], columns[1], columns[2]
times := make([]float64, 0, len(records)-1)
psiM := make([]float64, 0, len(records)-1)
psiL := make([]float64, 0, len(records)-1)
for _, record := range records[1:] {
if len(record) <= maxInt(tIndex, psiMIndex, psiLIndex) {
continue
}
t, err := parseFloat(record[tIndex])
if err != nil {
continue
}
m, err := parseFloat(record[psiMIndex])
if err != nil {
continue
}
l, err := parseFloat(record[psiLIndex])
if err != nil {
continue
}
times = append(times, t)
psiM = append(psiM, m)
psiL = append(psiL, l)
}
timeStep := averageDelta(times)
timeStep := series.AverageDelta(times)
if timeStep <= 0 {
ctx.JSON(http.StatusBadRequest, gin.H{"error": "invalid time step"})
return
@@ -264,72 +194,23 @@ func (c *ControlCaseController) PSISpectrum(ctx *gin.Context) {
ctx.JSON(http.StatusOK, PSISpectrumResponse{
TimeStep: timeStep,
Points: map[string][]FFTPoint{
"psi_m": buildSpectrumPoints(psiM, timeStep),
"psi_l": buildSpectrumPoints(psiL, timeStep),
"psi_m": series.SpectrumPoints(psiM, timeStep),
"psi_l": series.SpectrumPoints(psiL, timeStep),
},
})
}
func buildSpectrumPoints(values []float64, dt float64) []FFTPoint {
if len(values) < 2 || dt <= 0 {
return []FFTPoint{}
func readCSVResponse(ctx *gin.Context, csvPath string) (*series.CSVData, bool) {
data, err := series.ReadCSV(csvPath)
if err == nil {
return data, true
}
centered := make([]float64, len(values))
mean := 0.0
for _, v := range values {
mean += v
if errors.Is(err, os.ErrNotExist) {
ctx.JSON(http.StatusNotFound, gin.H{"error": "csv file not found"})
} else {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
}
mean /= float64(len(values))
for i, v := range values {
centered[i] = v - mean
}
fft := fourier.NewFFT(len(centered))
coeffs := fft.Coefficients(nil, centered)
limit := len(coeffs) / 2
points := make([]FFTPoint, 0, limit+1)
for i := 0; i <= limit; i++ {
points = append(points, FFTPoint{
Frequency: float64(i) / (float64(len(centered)) * dt),
Amplitude: math.Hypot(real(coeffs[i]), imag(coeffs[i])) / float64(len(centered)),
})
}
return points
}
func parseFloat(value string) (float64, error) {
return strconv.ParseFloat(strings.TrimSpace(value), 64)
}
func maxInt(values ...int) int {
max := 0
for _, v := range values {
if v > max {
max = v
}
}
return max
}
func averageDelta(times []float64) float64 {
if len(times) < 2 {
return 0
}
total := 0.0
count := 0
for i := 1; i < len(times); i++ {
delta := times[i] - times[i-1]
if delta <= 0 {
continue
}
total += delta
count++
}
if count == 0 {
return 0
}
return total / float64(count)
return nil, false
}
func (c *ControlCaseController) FieldMap(ctx *gin.Context) {
+8 -2
View File
@@ -14,7 +14,10 @@ import (
)
type ControlCase struct {
gorm.Model
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
Name string `json:"name"`
ParamsID uint `json:"params_id"`
Params Params `json:"params"`
@@ -59,7 +62,10 @@ type Params struct {
}
type InitialCondition struct {
gorm.Model
ID uint `gorm:"primaryKey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"deleted_at,omitempty"`
Name string `json:"name"`
FilePath string `json:"file_path"`
}
+114
View File
@@ -0,0 +1,114 @@
package series
import (
"encoding/csv"
"fmt"
"os"
"slices"
"strconv"
"strings"
)
type CSVData struct {
Columns []string
Rows [][]string
index map[string]int
}
func ReadCSV(path string) (*CSVData, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
records, err := csv.NewReader(file).ReadAll()
if err != nil {
return nil, err
}
if len(records) == 0 {
return &CSVData{Columns: []string{}, Rows: [][]string{}, index: map[string]int{}}, nil
}
data := &CSVData{
Columns: records[0],
Rows: records[1:],
index: make(map[string]int, len(records[0])),
}
for i, column := range data.Columns {
data.index[strings.ToLower(strings.TrimSpace(column))] = i
}
return data, nil
}
func (d *CSVData) Index(name string) (int, error) {
idx, ok := d.index[strings.ToLower(name)]
if !ok {
return -1, fmt.Errorf("missing column %q", name)
}
return idx, nil
}
func (d *CSVData) NumericColumns(names ...string) ([][]float64, error) {
indices := make([]int, len(names))
for i, name := range names {
idx, err := d.Index(name)
if err != nil {
return nil, err
}
indices[i] = idx
}
values := make([][]float64, len(names))
for i := range values {
values[i] = make([]float64, 0, len(d.Rows))
}
maxIndex := MaxInt(indices...)
for _, row := range d.Rows {
if len(row) <= maxIndex {
continue
}
parsed := make([]float64, len(indices))
valid := true
for i, idx := range indices {
value, err := ParseFloat(row[idx])
if err != nil {
valid = false
break
}
parsed[i] = value
}
if !valid {
continue
}
for i, value := range parsed {
values[i] = append(values[i], value)
}
}
return values, nil
}
func ParseFloat(value string) (float64, error) {
return strconv.ParseFloat(strings.TrimSpace(value), 64)
}
func MaxInt(values ...int) int {
max := 0
for _, value := range values {
if value > max {
max = value
}
}
return max
}
func MaxLastHalf(values []float64) float64 {
if len(values) == 0 {
return 0
}
return slices.Max(values[len(values)/2:])
}
+95
View File
@@ -0,0 +1,95 @@
package series
import (
"math"
"gonum.org/v1/gonum/dsp/fourier"
)
type SpectrumPoint struct {
Frequency float64 `json:"frequency"`
Amplitude float64 `json:"amplitude"`
}
func AverageDelta(times []float64) float64 {
if len(times) < 2 {
return 0
}
total := 0.0
count := 0
for i := 1; i < len(times); i++ {
delta := times[i] - times[i-1]
if delta <= 0 {
continue
}
total += delta
count++
}
if count == 0 {
return 0
}
return total / float64(count)
}
func SpectrumPoints(values []float64, dt float64) []SpectrumPoint {
if len(values) < 2 || dt <= 0 {
return []SpectrumPoint{}
}
centered := Centered(values)
fft := fourier.NewFFT(len(centered))
coeffs := fft.Coefficients(nil, centered)
limit := len(coeffs) / 2
points := make([]SpectrumPoint, 0, limit+1)
for i := 0; i <= limit; i++ {
points = append(points, SpectrumPoint{
Frequency: float64(i) / (float64(len(centered)) * dt),
Amplitude: math.Hypot(real(coeffs[i]), imag(coeffs[i])) / float64(len(centered)),
})
}
return points
}
func MainAngularFrequency(times, values []float64) float64 {
if len(values) < 2 || len(times) < 2 {
return 0
}
dt := AverageDelta(times)
if dt <= 0 {
return 0
}
centered := Centered(values)
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 := math.Hypot(real(coeffs[i]), imag(coeffs[i]))
if amp > bestAmp {
bestAmp = amp
bestIndex = i
}
}
frequencyHz := fft.Freq(bestIndex) / dt
return 2 * math.Pi * frequencyHz
}
func Centered(values []float64) []float64 {
centered := make([]float64, len(values))
mean := 0.0
for _, value := range values {
mean += value
}
mean /= float64(len(values))
for i, value := range values {
centered[i] = value - mean
}
return centered
}