|
@@ -0,0 +1,376 @@
|
|
|
|
|
+package tools
|
|
|
|
|
+
|
|
|
|
|
+import (
|
|
|
|
|
+ "context"
|
|
|
|
|
+ "encoding/json"
|
|
|
|
|
+ "fmt"
|
|
|
|
|
+ "sort"
|
|
|
|
|
+ "strconv"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+
|
|
|
|
|
+ "github.com/mark3labs/mcp-go/mcp"
|
|
|
|
|
+
|
|
|
|
|
+ "fwh-toolbox-server/agentmcp/kit"
|
|
|
|
|
+ "fwh-toolbox-server/dto"
|
|
|
|
|
+ "fwh-toolbox-server/services"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+func parseEnv(v string) (string, error) {
|
|
|
|
|
+ return kit.Enum("env", v, "prod", "prod", "test")
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---- tou_template_list ----
|
|
|
|
|
+
|
|
|
|
|
+type touTemplateListArgs struct {
|
|
|
|
|
+ Env string `json:"env,omitempty" jsonschema:"服务器环境:prod(生产,默认)/ test(测试)"`
|
|
|
|
|
+ Search string `json:"search,omitempty" jsonschema:"搜索关键字(模版名称、电力公司等)"`
|
|
|
|
|
+ Type *int `json:"type,omitempty" jsonschema:"模版类型:0 系统模版、1 API 模版、2 用户推荐;不填为全部"`
|
|
|
|
|
+ Status *int `json:"status,omitempty" jsonschema:"发布状态:0 未发布、2 已发布;不填为全部"`
|
|
|
|
|
+ Page int `json:"page,omitempty" jsonschema:"页码,默认 1"`
|
|
|
|
|
+ Size int `json:"size,omitempty" jsonschema:"每页条数,1~100,默认 20"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+var TouTemplateList = kit.Def[touTemplateListArgs]{
|
|
|
|
|
+ Name: "tou_template_list",
|
|
|
|
|
+ Title: "查询 TOU 调度模版列表",
|
|
|
|
|
+ Description: "分页查询 FWH 云平台的 TOU 调度模版(生产服或测试服)。返回 total 与 items(模版 ID、名称、类型、国家/地区、电力公司、电价类型、发布状态、更新时间等,以实际返回为准)。" +
|
|
|
|
|
+ "查看某个模版的时段与调度模式用 tou_template_get。",
|
|
|
|
|
+ ReadOnly: true,
|
|
|
|
|
+ Handler: func(ctx context.Context, a touTemplateListArgs) (*mcp.CallToolResult, error) {
|
|
|
|
|
+ env, err := parseEnv(a.Env)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return kit.Fail(err), nil
|
|
|
|
|
+ }
|
|
|
|
|
+ page := kit.Page(a.Page)
|
|
|
|
|
+ size := kit.Limit(a.Size, 20, 100)
|
|
|
|
|
+ var data *dto.ResPage[interface{}]
|
|
|
|
|
+ if env == "test" {
|
|
|
|
|
+ data, err = services.FwhTestGetTouTemplateProList(uint(page), uint(size), a.Type, a.Status, strings.TrimSpace(a.Search))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ data, err = services.GetTouTemplateProList(uint(page), uint(size), a.Type, a.Status, strings.TrimSpace(a.Search))
|
|
|
|
|
+ }
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return kit.Upstream("FWH 平台 TOU 模版接口("+env+")", err), nil
|
|
|
|
|
+ }
|
|
|
|
|
+ if data == nil {
|
|
|
|
|
+ return kit.Empty(""), nil
|
|
|
|
|
+ }
|
|
|
|
|
+ return kit.List(data.Data, int(data.Total), size, fmt.Sprintf("共 %d 条,当前第 %d 页,可用 page 翻页", data.Total, page)), nil
|
|
|
|
|
+ },
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---- tou_template_get ----
|
|
|
|
|
+
|
|
|
|
|
+type touTemplateGetArgs struct {
|
|
|
|
|
+ Env string `json:"env,omitempty" jsonschema:"服务器环境:prod(生产,默认)/ test(测试)"`
|
|
|
|
|
+ ID int `json:"id" jsonschema:"模版 ID"`
|
|
|
|
|
+ Raw bool `json:"raw,omitempty" jsonschema:"为 true 时返回云端原始详情(字段完整但较长),默认 false 返回整理后的结构"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+var TouTemplateGet = kit.Def[touTemplateGetArgs]{
|
|
|
|
|
+ Name: "tou_template_get",
|
|
|
|
|
+ Title: "查询 TOU 调度模版详情",
|
|
|
|
|
+ Description: "查询单个 TOU 调度模版的详情,默认整理为:template(模版主体字段)与 strategies(按季节 × 日类型的策略)。" +
|
|
|
|
|
+ "每个策略含 seasonName、month、dayName、ccociateSn、电价,以及 periods(时段):start、end、waveType(谷/平/峰/尖峰/低谷)、" +
|
|
|
|
|
+ "solarPriority / loadPriority(原始值)、mode(识别出的调度模式:A~H、E/G、F/H、强制充电、强制放电、Zero Import、未设置、自定义)、params(设置了的扩展参数,-1 视为未设置)、issues(异常说明)。" +
|
|
|
|
|
+ "优先级顺序:光伏 [市电, 负载, aPower],负载 [市电, 光伏, aPower]。raw=true 返回原始详情。",
|
|
|
|
|
+ ReadOnly: true,
|
|
|
|
|
+ Handler: func(ctx context.Context, a touTemplateGetArgs) (*mcp.CallToolResult, error) {
|
|
|
|
|
+ env, err := parseEnv(a.Env)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return kit.Fail(err), nil
|
|
|
|
|
+ }
|
|
|
|
|
+ if a.ID <= 0 {
|
|
|
|
|
+ return kit.Failf("参数 id 必填且为正整数"), nil
|
|
|
|
|
+ }
|
|
|
|
|
+ var data *interface{}
|
|
|
|
|
+ if env == "test" {
|
|
|
|
|
+ data, err = services.FwhTestGetTouTemplateProDetailInfo(uint(a.ID))
|
|
|
|
|
+ } else {
|
|
|
|
|
+ data, err = services.GetTouTemplateProDetailInfo(uint(a.ID))
|
|
|
|
|
+ }
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return kit.Upstream("FWH 平台 TOU 模版接口("+env+")", err), nil
|
|
|
|
|
+ }
|
|
|
|
|
+ if data == nil || *data == nil {
|
|
|
|
|
+ return kit.Empty("模版不存在。"), nil
|
|
|
|
|
+ }
|
|
|
|
|
+ if a.Raw {
|
|
|
|
|
+ return kit.Object(*data), nil
|
|
|
|
|
+ }
|
|
|
|
|
+ organized, ok := OrganizeTouDetail(*data)
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ // 结构与预期不符时退回原始详情,避免整理失败导致无结果
|
|
|
|
|
+ return kit.Object(*data), nil
|
|
|
|
|
+ }
|
|
|
|
|
+ return kit.Object(organized), nil
|
|
|
|
|
+ },
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ---- 详情整理与调度模式识别(规则见 docs/despatch-config/requirements.md 第 3 节) ----
|
|
|
|
|
+
|
|
|
|
|
+var waveTypeNames = map[int]string{0: "谷", 1: "平", 2: "峰", 3: "尖峰", 4: "低谷"}
|
|
|
|
|
+
|
|
|
|
|
+// touParamKeys 时段中的扩展参数(-1 为未设置的哨兵值)
|
|
|
|
|
+var touParamKeys = []string{
|
|
|
|
|
+ "gridDischargeMax", "gridChargeMax", "chargePower", "dischargePower", "chargeMax", "dischargeMax",
|
|
|
|
|
+ "gcaoMax", "gridFeedMax", "gridMax", "minDischargeSoc", "maxChargeSoc", "heatEnable", "powerOffApower",
|
|
|
|
|
+ "rampTime", "offGrid", "solarCutoff",
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// baseModes 基础模式:归一化后的光伏/负载优先级 → 模式
|
|
|
|
|
+var baseModes = []struct {
|
|
|
|
|
+ Name, Solar, Load string
|
|
|
|
|
+}{
|
|
|
|
|
+ {"A", "1,2,0", "1,3,2"},
|
|
|
|
|
+ {"B", "1,2,0", "1,2,0"},
|
|
|
|
|
+ {"C", "1,3,2", "1,2,0"},
|
|
|
|
|
+ {"D", "1,3,2", "1,3,2"},
|
|
|
|
|
+ {"E/G", "1,2,3", "1,2,0"},
|
|
|
|
|
+ {"F/H", "1,2,0", "1,2,3"},
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// TouPeriod 整理后的时段
|
|
|
|
|
+type TouPeriod struct {
|
|
|
|
|
+ Start string `json:"start"`
|
|
|
|
|
+ End string `json:"end"`
|
|
|
|
|
+ WaveType string `json:"waveType,omitempty"`
|
|
|
|
|
+ Name string `json:"name,omitempty"`
|
|
|
|
|
+ SolarPriority string `json:"solarPriority"`
|
|
|
|
|
+ LoadPriority string `json:"loadPriority"`
|
|
|
|
|
+ Mode string `json:"mode"`
|
|
|
|
|
+ Params map[string]any `json:"params,omitempty"`
|
|
|
|
|
+ Issues []string `json:"issues,omitempty"`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// OrganizeTouDetail 把云端详情整理为 template + strategies;找不到 strategyList 时返回 false。
|
|
|
|
|
+func OrganizeTouDetail(detail any) (map[string]any, bool) {
|
|
|
|
|
+ b, err := json.Marshal(detail)
|
|
|
|
|
+ if err != nil {
|
|
|
|
|
+ return nil, false
|
|
|
|
|
+ }
|
|
|
|
|
+ var root map[string]any
|
|
|
|
|
+ if err := json.Unmarshal(b, &root); err != nil {
|
|
|
|
|
+ return nil, false
|
|
|
|
|
+ }
|
|
|
|
|
+ // 兼容 { template, strategyList } 与 { ...模版字段, strategyList } 两种形态
|
|
|
|
|
+ strategies, _ := root["strategyList"].([]any)
|
|
|
|
|
+ template := root
|
|
|
|
|
+ if t, ok := root["template"].(map[string]any); ok {
|
|
|
|
|
+ template = t
|
|
|
|
|
+ if strategies == nil {
|
|
|
|
|
+ strategies, _ = t["strategyList"].([]any)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if strategies == nil {
|
|
|
|
|
+ return nil, false
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ tpl := map[string]any{}
|
|
|
|
|
+ for k, v := range template {
|
|
|
|
|
+ if k != "strategyList" {
|
|
|
|
|
+ tpl[k] = v
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ out := make([]map[string]any, 0, len(strategies))
|
|
|
|
|
+ for _, s := range strategies {
|
|
|
|
|
+ sm, ok := s.(map[string]any)
|
|
|
|
|
+ if !ok {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ st := map[string]any{}
|
|
|
|
|
+ for _, k := range []string{"seasonName", "month", "dayName", "dayType", "ccociateType", "ccociateSn"} {
|
|
|
|
|
+ if v, ok := sm[k]; ok {
|
|
|
|
|
+ st[k] = v
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ rates := map[string]any{}
|
|
|
|
|
+ for k, v := range sm {
|
|
|
|
|
+ if strings.HasPrefix(k, "eleticRate") || strings.HasPrefix(k, "eleticSell") {
|
|
|
|
|
+ rates[k] = v
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(rates) > 0 {
|
|
|
|
|
+ st["rates"] = rates
|
|
|
|
|
+ }
|
|
|
|
|
+ details, _ := sm["detailVoList"].([]any)
|
|
|
|
|
+ periods := make([]TouPeriod, 0, len(details))
|
|
|
|
|
+ for _, d := range details {
|
|
|
|
|
+ if dm, ok := d.(map[string]any); ok {
|
|
|
|
|
+ periods = append(periods, organizePeriod(dm))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ sort.SliceStable(periods, func(i, j int) bool { return periods[i].Start < periods[j].Start })
|
|
|
|
|
+ st["periods"] = periods
|
|
|
|
|
+ if issues := checkCoverage(periods); len(issues) > 0 {
|
|
|
|
|
+ st["issues"] = issues
|
|
|
|
|
+ }
|
|
|
|
|
+ out = append(out, st)
|
|
|
|
|
+ }
|
|
|
|
|
+ return map[string]any{"template": tpl, "strategies": out}, true
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func organizePeriod(dm map[string]any) TouPeriod {
|
|
|
|
|
+ p := TouPeriod{
|
|
|
|
|
+ Start: str(dm["startHourTime"]),
|
|
|
|
|
+ End: str(dm["endHourTime"]),
|
|
|
|
|
+ Name: str(dm["name"]),
|
|
|
|
|
+ SolarPriority: str(dm["solarPriority"]),
|
|
|
|
|
+ LoadPriority: str(dm["loadPriority"]),
|
|
|
|
|
+ }
|
|
|
|
|
+ if w, ok := num(dm["waveType"]); ok {
|
|
|
|
|
+ if n, ok := waveTypeNames[int(w)]; ok {
|
|
|
|
|
+ p.WaveType = n
|
|
|
|
|
+ } else {
|
|
|
|
|
+ p.WaveType = fmt.Sprintf("未知(%v)", w)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ params := map[string]any{}
|
|
|
|
|
+ for _, k := range touParamKeys {
|
|
|
|
|
+ v, ok := dm[k]
|
|
|
|
|
+ if !ok || v == nil || str(v) == "" {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ if n, isNum := num(v); isNum && n == -1 {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ params[k] = v
|
|
|
|
|
+ }
|
|
|
|
|
+ if len(params) > 0 {
|
|
|
|
|
+ p.Params = params
|
|
|
|
|
+ }
|
|
|
|
|
+ p.Mode, p.Issues = RecognizeMode(p.SolarPriority, p.LoadPriority, params)
|
|
|
|
|
+ return p
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// RecognizeMode 识别调度模式。params 只包含"设置了"的参数(有值且不为 -1)。
|
|
|
|
|
+func RecognizeMode(solar, load string, params map[string]any) (string, []string) {
|
|
|
|
|
+ solar, load = strings.TrimSpace(solar), strings.TrimSpace(load)
|
|
|
|
|
+ var issues []string
|
|
|
|
|
+
|
|
|
|
|
+ // 1. 特殊模式按原始值精确匹配
|
|
|
|
|
+ if solar == "9,9,9" && load == "9,9,9" {
|
|
|
|
|
+ for k, want := range map[string]float64{"gridDischargeMax": 0.1, "gridFeedMax": 0.1} {
|
|
|
|
|
+ if got, ok := num(params[k]); !ok || got != want {
|
|
|
|
|
+ issues = append(issues, fmt.Sprintf("Zero Import 的 %s 应为 %v,实际为 %v", k, want, params[k]))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ sort.Strings(issues)
|
|
|
|
|
+ return "Zero Import", issues
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 2. 两个优先级都为空
|
|
|
|
|
+ if solar == "" && load == "" {
|
|
|
|
|
+ _, hasCharge := params["chargePower"]
|
|
|
|
|
+ _, hasDischarge := params["dischargePower"]
|
|
|
|
|
+ switch {
|
|
|
|
|
+ case hasCharge && !hasDischarge:
|
|
|
|
|
+ return "强制充电", nil
|
|
|
|
|
+ case hasDischarge && !hasCharge:
|
|
|
|
|
+ return "强制放电", nil
|
|
|
|
|
+ case !hasCharge && !hasDischarge:
|
|
|
|
|
+ return "未设置", nil
|
|
|
|
|
+ default:
|
|
|
|
|
+ return "自定义", []string{"优先级为空但同时设置了 chargePower 和 dischargePower"}
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if solar == "" || load == "" {
|
|
|
|
|
+ return "自定义", []string{"光伏与负载优先级只有一个为空"}
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // 3. 归一化后匹配 A~H
|
|
|
|
|
+ ns, ok1 := normalizePriority(solar)
|
|
|
|
|
+ nl, ok2 := normalizePriority(load)
|
|
|
|
|
+ if ok1 && ok2 {
|
|
|
|
|
+ for _, m := range baseModes {
|
|
|
|
|
+ if ns == m.Solar && nl == m.Load {
|
|
|
|
|
+ return m.Name, nil
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return "自定义", nil
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// normalizePriority 稠密排名归一化:0 保持 0,非 0 值按从小到大映射为 1、2、3,相同值映射为同一个数。
|
|
|
|
|
+func normalizePriority(s string) (string, bool) {
|
|
|
|
|
+ parts := strings.Split(s, ",")
|
|
|
|
|
+ if len(parts) != 3 {
|
|
|
|
|
+ return "", false
|
|
|
|
|
+ }
|
|
|
|
|
+ vals := make([]int, 3)
|
|
|
|
|
+ uniq := map[int]bool{}
|
|
|
|
|
+ for i, p := range parts {
|
|
|
|
|
+ v, err := strconv.Atoi(strings.TrimSpace(p))
|
|
|
|
|
+ if err != nil || v < 0 {
|
|
|
|
|
+ return "", false
|
|
|
|
|
+ }
|
|
|
|
|
+ vals[i] = v
|
|
|
|
|
+ if v != 0 {
|
|
|
|
|
+ uniq[v] = true
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ sorted := make([]int, 0, len(uniq))
|
|
|
|
|
+ for v := range uniq {
|
|
|
|
|
+ sorted = append(sorted, v)
|
|
|
|
|
+ }
|
|
|
|
|
+ sort.Ints(sorted)
|
|
|
|
|
+ rank := map[int]int{}
|
|
|
|
|
+ for i, v := range sorted {
|
|
|
|
|
+ rank[v] = i + 1
|
|
|
|
|
+ }
|
|
|
|
|
+ out := make([]string, 3)
|
|
|
|
|
+ for i, v := range vals {
|
|
|
|
|
+ if v == 0 {
|
|
|
|
|
+ out[i] = "0"
|
|
|
|
|
+ } else {
|
|
|
|
|
+ out[i] = strconv.Itoa(rank[v])
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return strings.Join(out, ","), true
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// checkCoverage 检查时段是否首尾衔接覆盖 00:00~24:00。
|
|
|
|
|
+func checkCoverage(ps []TouPeriod) []string {
|
|
|
|
|
+ if len(ps) == 0 {
|
|
|
|
|
+ return []string{"没有时段"}
|
|
|
|
|
+ }
|
|
|
|
|
+ var issues []string
|
|
|
|
|
+ if ps[0].Start != "00:00" {
|
|
|
|
|
+ issues = append(issues, "第一个时段不是从 00:00 开始")
|
|
|
|
|
+ }
|
|
|
|
|
+ for i := 1; i < len(ps); i++ {
|
|
|
|
|
+ if ps[i].Start != ps[i-1].End {
|
|
|
|
|
+ issues = append(issues, fmt.Sprintf("%s 与 %s 之间不衔接(%s → %s)", ps[i-1].End, ps[i].Start, ps[i-1].End, ps[i].Start))
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if last := ps[len(ps)-1].End; last != "24:00" && last != "00:00" && last != "23:59" {
|
|
|
|
|
+ issues = append(issues, "最后一个时段没有到 24:00")
|
|
|
|
|
+ }
|
|
|
|
|
+ return issues
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func str(v any) string {
|
|
|
|
|
+ switch t := v.(type) {
|
|
|
|
|
+ case nil:
|
|
|
|
|
+ return ""
|
|
|
|
|
+ case string:
|
|
|
|
|
+ return t
|
|
|
|
|
+ case float64:
|
|
|
|
|
+ return strconv.FormatFloat(t, 'f', -1, 64)
|
|
|
|
|
+ default:
|
|
|
|
|
+ return fmt.Sprint(t)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+func num(v any) (float64, bool) {
|
|
|
|
|
+ switch t := v.(type) {
|
|
|
|
|
+ case float64:
|
|
|
|
|
+ return t, true
|
|
|
|
|
+ case int:
|
|
|
|
|
+ return float64(t), true
|
|
|
|
|
+ case string:
|
|
|
|
|
+ f, err := strconv.ParseFloat(strings.TrimSpace(t), 64)
|
|
|
|
|
+ return f, err == nil
|
|
|
|
|
+ }
|
|
|
|
|
+ return 0, false
|
|
|
|
|
+}
|