package service import ( "context" "os" "path/filepath" "sort" "strings" "sync" "time" "predictor-refactored/internal/dataset" "predictor-refactored/internal/downloader" "predictor-refactored/internal/elevation" "go.uber.org/zap" ) // Service orchestrates the dataset lifecycle and provides prediction capabilities. type Service struct { mu sync.RWMutex ds *dataset.File elev *elevation.Dataset cfg *downloader.Config dl *downloader.Downloader log *zap.Logger updating sync.Mutex // prevents concurrent downloads } // New creates a new Service. func New(cfg *downloader.Config, log *zap.Logger) *Service { return &Service{ cfg: cfg, dl: downloader.NewDownloader(cfg, log), log: log, } } // LoadElevation loads the ruaumoko-compatible elevation dataset from path. // If the file doesn't exist, elevation termination is disabled (falls back to sea level). func (s *Service) LoadElevation(path string) { ds, err := elevation.Open(path) if err != nil { s.log.Warn("elevation dataset not available, using sea-level termination", zap.String("path", path), zap.Error(err)) return } s.elev = ds s.log.Info("elevation dataset loaded", zap.String("path", path)) } // Elevation returns the elevation dataset (may be nil). func (s *Service) Elevation() *elevation.Dataset { return s.elev } // Ready returns true if the service has a loaded dataset. func (s *Service) Ready() bool { s.mu.RLock() defer s.mu.RUnlock() return s.ds != nil } // DatasetTime returns the forecast time of the currently loaded dataset. func (s *Service) DatasetTime() (time.Time, bool) { s.mu.RLock() defer s.mu.RUnlock() if s.ds == nil { return time.Time{}, false } return s.ds.DSTime, true } // Dataset returns the current dataset for reading. func (s *Service) Dataset() *dataset.File { s.mu.RLock() defer s.mu.RUnlock() return s.ds } // Update checks for and downloads new forecast data if needed. func (s *Service) Update(ctx context.Context) error { if !s.updating.TryLock() { s.log.Info("update already in progress, skipping") return nil } defer s.updating.Unlock() // Check if current dataset is still fresh if dsTime, ok := s.DatasetTime(); ok { if time.Since(dsTime) < s.cfg.DatasetTTL { s.log.Info("dataset still fresh, skipping update", zap.Time("dataset_time", dsTime), zap.Duration("age", time.Since(dsTime))) return nil } } // Try loading an existing dataset from disk first if err := s.loadExistingDataset(); err == nil { return nil } // Find latest available model run run, err := s.dl.FindLatestRun(ctx) if err != nil { return err } // Download and assemble path, err := s.dl.Download(ctx, run) if err != nil { return err } // Open the new dataset ds, err := dataset.Open(path, run) if err != nil { return err } // Swap in the new dataset s.setDataset(ds) s.log.Info("dataset loaded", zap.Time("run", run), zap.String("path", path)) // Clean old datasets s.cleanOldDatasets(path) return nil } // loadExistingDataset tries to find and load an existing dataset from the data directory. func (s *Service) loadExistingDataset() error { entries, err := os.ReadDir(s.cfg.DataDir) if err != nil { return err } // Collect valid dataset files (name is YYYYMMDDHH, no extension, correct size) type candidate struct { name string path string run time.Time } var candidates []candidate for _, e := range entries { if e.IsDir() || strings.Contains(e.Name(), ".") { continue } if len(e.Name()) != 10 { continue } run, err := time.Parse("2006010215", e.Name()) if err != nil { continue } path := filepath.Join(s.cfg.DataDir, e.Name()) info, err := os.Stat(path) if err != nil || info.Size() != dataset.DatasetSize { continue } if time.Since(run) > s.cfg.DatasetTTL { continue } candidates = append(candidates, candidate{name: e.Name(), path: path, run: run}) } if len(candidates) == 0 { return os.ErrNotExist } // Pick the newest sort.Slice(candidates, func(i, j int) bool { return candidates[i].run.After(candidates[j].run) }) best := candidates[0] ds, err := dataset.Open(best.path, best.run) if err != nil { return err } s.setDataset(ds) s.log.Info("loaded existing dataset", zap.Time("run", best.run), zap.String("path", best.path)) return nil } // setDataset swaps the current dataset with a new one, closing the old one. func (s *Service) setDataset(ds *dataset.File) { s.mu.Lock() old := s.ds s.ds = ds s.mu.Unlock() if old != nil { if err := old.Close(); err != nil { s.log.Error("failed to close old dataset", zap.Error(err)) } } } // cleanOldDatasets removes dataset files other than the one at keepPath. func (s *Service) cleanOldDatasets(keepPath string) { entries, err := os.ReadDir(s.cfg.DataDir) if err != nil { return } for _, e := range entries { if e.IsDir() { continue } path := filepath.Join(s.cfg.DataDir, e.Name()) if path == keepPath { continue } // Remove old datasets and temp files if len(e.Name()) == 10 || strings.HasSuffix(e.Name(), ".downloading") { if err := os.Remove(path); err != nil { s.log.Warn("failed to remove old file", zap.String("path", path), zap.Error(err)) } else { s.log.Info("removed old dataset", zap.String("path", path)) } } } } // Close releases all resources. func (s *Service) Close() error { s.mu.Lock() defer s.mu.Unlock() if s.ds != nil { err := s.ds.Close() s.ds = nil return err } return nil }