Compare commits

...

10 Commits

Author SHA1 Message Date
d391066e5b add map 2025-05-23 07:00:44 +00:00
918e380f56 optimizations 2025-05-22 15:24:59 +00:00
9b5fcd0100 adapt mecator
update interface
adapt time base before converting to grib
refactor some stuff
remove debug comments
2025-05-21 19:13:46 +00:00
c05ee25923 modification en projection mercator pour la latitude 2025-05-20 16:01:20 +00:00
11b47b1549 init 2025-05-20 15:09:33 +00:00
922f2cd134 Delete iface.py 2025-05-20 17:05:13 +02:00
91824db369 Delete downloader.py 2025-05-20 17:05:00 +02:00
f40c303726 Delete DownloadCurrents.py 2025-05-20 17:04:44 +02:00
cd9b4d41c6 Edit README.md 2025-05-20 09:36:09 +02:00
579d01da45 add works in progress 2025-05-20 06:14:36 +00:00
8 changed files with 673 additions and 195 deletions

View File

@@ -26,12 +26,13 @@ import bz2
import subprocess
import pygrib
import re
from datetime import date
from datetime import date,timedelta
def build_url(base_url, basename):
def build_url(base_url, basename,previousday=0):
# today = datetime.datetime.utcnow().strftime("%d%m%y")
today = date.today().strftime("%y%m%d")
filename = f"{basename}_{today}-12.grb.bz2"
today = date.today()
target = (today - timedelta(previousday)).strftime("%y%m%d")
filename = f"{basename}_{target}-12.grb.bz2"
grib_filename = filename.replace(".bz2", "")
return base_url + filename, filename, grib_filename
@@ -165,12 +166,12 @@ def main():
merged_filename = "merged.grb"
url, bz2_filename, grib_a_filename = build_url("https://openskiron.org/gribs_wrf_4km/","Dunkirk_4km_WRF_WAM")
#download_file(url, bz2_filename)
#decompress_bz2(bz2_filename, grib_a_filename)
download_file(url, bz2_filename)
decompress_bz2(bz2_filename, grib_a_filename)
url, bz2_filename, grib_b_filename = build_url("https://openskiron.org/gribs_wrf_4km/","Hastings_4km_WRF_WAM")
#download_file(url, bz2_filename)
#decompress_bz2(bz2_filename, grib_b_filename)
download_file(url, bz2_filename)
decompress_bz2(bz2_filename, grib_b_filename)
extract_surface_current(grib_a_filename,"surfacea.grb")
extract_surface_current(grib_b_filename,"surfaceb.grb")

View File

@@ -4,24 +4,28 @@
## Objective
Retrieve grib files containing water surface current for the regions between holland and france south west, including east uk coasts.
Files must be compatible for common opensource navigation tools like (qtvlm, opencpn and xygrib grib visualization tool)
Provide grib files containing water surface current compatible with the most common navigation tools (opencpn, qtvlm, ...)
## Data Sources
### copernicus
probably the most accurate source for european waters.
- https://data.marine.copernicus.eu/product/IBI_ANALYSISFORECAST_PHY_005_001/services
### openskiron
provide interesting grib for several EU regions.
- https://openskiron.org/en/openwrf
The data are provided by the IBI-MFC. Thanks to them for their works.
The data covers the Iberia-Biscay-Ireland region with a resolution of 0.028° and 10 days of forecasts
More informations available on : https://data.marine.copernicus.eu/product/IBI_ANALYSISFORECAST_PHY_005_001/description
## What the tool does
- [ ] Retrieve data from these Sources
- [ ] Clean data, preserve only surface current, and crop grid to the requested zone.
- [ ] Transform and format these data to a standardized grib file
- [ ] Provide a user-friendly interface to select the zone.
- [ ] Transform data's to an exploitable grid2 file.
## Prerequisites and Installation
- Runs under linux or windows (with wsl)
- Requires python3 with the following dependencies
- - python3-tk
- - python3-pil
- - python3-pil.imagetk
- - copernicusmarine
## License

115
downloader.py Normal file
View File

@@ -0,0 +1,115 @@
import os
from datetime import datetime, timedelta
from copernicusmarine import subset
import subprocess
class CopernicusDownloader:
def __init__(self, username="", password=""):
self.dataset_id = "cmems_mod_ibi_phy_anfc_0.027deg-2D_PT1H-m"
self.username = username
self.password = password
self.output_file = os.path.join(os.getcwd(), "downloads", "download.nc")
self.prepared_file = os.path.join(os.getcwd(), "downloads", "download_prepared.nc")
self.output_grib = os.path.join(os.getcwd(), "downloads", "download.grib2")
self.lat_min = 0.0
self.lat_max = 0.0
self.lon_min = 0.0
self.lon_max = 0.0
self.days = 1
def retrieve_grib2(self, lat_min, lat_max, lon_min, lon_max, days):
self.lat_min = lat_min
self.lat_max = lat_max
self.lon_min = lon_min
self.lon_max = lon_max
self.days = days
# print(f"Démarage du téléchargement avec {lat_min} - {lat_max} - {lon_min} - {lon_max} - {days} ")
self.download(self.output_file)
self.apply_setreftime(self.output_file,self.prepared_file)
self.convert_to_grib2(self.prepared_file,self.output_grib)
self.file_remove(self.output_file)
return self.output_grib
def file_remove(self,file):
if os.path.exists(file):
os.remove(file)
print(f"✅ File removed : {file}")
else:
print(f"❌ File {file} does not exist.")
def download(self,outfile):
today = datetime.utcnow()
start_date = today.strftime("%Y-%m-%dT00:00:00")
end_date = (today + timedelta(days=self.days)).strftime("%Y-%m-%dT23:00:00")
save_dir = os.path.join(os.getcwd(), "downloads")
os.makedirs(save_dir, exist_ok=True)
subset(
dataset_id=self.dataset_id,
output_filename=outfile,
variables=["uo", "vo"], # removed "thetao" (temperature) and "zos" (elevation)
minimum_longitude=self.lon_min,
maximum_longitude=self.lon_max,
minimum_latitude=self.lat_min,
maximum_latitude=self.lat_max,
start_datetime=start_date,
end_datetime=end_date,
username=self.username,
password=self.password
)
print(f"✅ File downloaded to : {outfile}")
def extract_date_and_time(self,infile):
# Extract the first timecode of the nc file
try:
result = subprocess.run(
["cdo", "showtimestamp", infile],
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True
)
# Récupérer le premier timestamp : ex. 2025-05-21T00:00:00
first_timestamp = result.stdout.strip().split()[0]
date, time = first_timestamp.split('T')
return date, time
except subprocess.CalledProcessError as e:
print(f"[CDO Error] {e.stderr}")
except Exception as e:
print(f"[Error] {e}")
return None, None
def apply_setreftime(self,infile,outfile):
# Apply the command cdo -setreftime,<date>,<heure> <input> <output> to define a reference time
date, time = self.extract_date_and_time(infile)
if not date or not time:
print("Cannot retrieve date/time from this file.")
return
try:
subprocess.run(
["cdo", f"-setreftime,{date},{time}", infile, outfile],
check=True
)
print(f"✅ File adapted with correct time ref {date} - {time} : {outfile}")
except subprocess.CalledProcessError as e:
print(f"[Error when setting reference time with CDO -setreftime] {e.stderr}")
def convert_to_grib2(self,infile,outfile):
if not os.path.exists(infile):
raise FileNotFoundError(f"File not found : {infile}")
try:
subprocess.run(["cdo", "-f", "grb2", "copy", infile, outfile], check=True)
print(f"✅ File converted to grib : {outfile}")
return outfile
except subprocess.CalledProcessError as e:
raise RuntimeError(f"Error when converting with CDO : {e}")

356
iface.py Normal file
View File

@@ -0,0 +1,356 @@
import tkinter as tk
from tkinter import ttk
from tkinter import messagebox
from PIL import Image, ImageTk
import threading
import os
import platform
import subprocess
import configparser
import webbrowser
from math import log, tan, radians, pi, degrees, atan, sinh
from downloader import CopernicusDownloader
CONFIG_FILE = "config.ini"
class MapSelector:
def __init__(self, root):
self.root = root
self.root.title("Surface Current Downloader")
self.config = configparser.ConfigParser()
self.load_config()
self.notebook = ttk.Notebook(self.root)
self.notebook.pack(fill=tk.BOTH, expand=True)
self.frame_download = ttk.Frame(self.notebook)
self.frame_config = ttk.Frame(self.notebook)
self.frame_about = ttk.Frame(self.notebook)
self.notebook.add(self.frame_download, text="Download Manager")
self.notebook.add(self.frame_config, text="Configuration")
self.notebook.add(self.frame_about, text="About")
self.downloader = CopernicusDownloader(
username=self.config.get("AUTH", "username", fallback=""),
password=self.config.get("AUTH", "password", fallback="")
)
self.initialized = False
self.scale = 1.0 # Facteur de zoom initial
self.min_scale = 0.3 # Zoom minimum
self.max_scale = 2.0 # Zoom maximum
self.offset_x = 0 # Décalage horizontal
self.offset_y = 0 # Décalage vertical
self.start_pan_x = 0 # Position initiale du clic pour le déplacement
self.start_pan_y = 0
self.setup_download_tab()
self.setup_config_tab()
def load_config(self):
if not os.path.exists(CONFIG_FILE):
self.config["AUTH"] = {"username": "", "password": ""}
with open(CONFIG_FILE, "w") as f:
self.config.write(f)
else:
self.config.read(CONFIG_FILE)
def save_config(self):
with open(CONFIG_FILE, "w") as f:
self.config.write(f)
def setup_download_tab(self):
# Canvas et image
self.canvas = tk.Canvas(self.frame_download, cursor="cross")
self.canvas.pack(fill=tk.BOTH, expand=True)
filename = self.config['map']['filename'].strip('"')
self.original_img = Image.open(filename)
self.display_img = self.original_img.copy()
self.tk_img = ImageTk.PhotoImage(self.display_img)
self.img_id = self.canvas.create_image(self.offset_x, self.offset_y, anchor="nw", image=self.tk_img)
self.canvas.bind("<Configure>", self.on_canvas_configure)
self.canvas.bind("<Button-1>", self.on_click)
self.canvas.bind("<B1-Motion>", self.on_drag)
self.canvas.bind("<ButtonRelease-1>", self.on_release)
self.canvas.bind("<MouseWheel>", self.setzoom) # Windows
self.canvas.bind("<Button-4>", self.setzoom) # Linux scroll up
self.canvas.bind("<Button-5>", self.setzoom) # Linux scroll down
self.canvas.bind("<ButtonPress-3>", self.start_pan)
self.canvas.bind("<B3-Motion>", self.pan_image)
self.start_x = self.start_y = None
self.rect = None
params_frame = ttk.Frame(self.frame_download) #, borderwidth=1, relief="solid")
params_frame.pack(pady=5)
#params_frame.pack(pady=5)
self.lat1_var = tk.StringVar()
self.lon1_var = tk.StringVar()
self.lat2_var = tk.StringVar()
self.lon2_var = tk.StringVar()
ttk.Label(params_frame, text="Lat Min:").grid(row=0, column=0)
ttk.Entry(params_frame, textvariable=self.lat1_var, width=10).grid(row=0, column=1)
params_frame.grid_columnconfigure(2, minsize=20)
ttk.Label(params_frame, text="Lon Min:").grid(row=0, column=3)
ttk.Entry(params_frame, textvariable=self.lon1_var, width=10).grid(row=0, column=4)
params_frame.grid_columnconfigure(5, minsize=20)
ttk.Label(params_frame, text="Lat Max:").grid(row=1, column=0)
ttk.Entry(params_frame, textvariable=self.lat2_var, width=10).grid(row=1, column=1)
ttk.Label(params_frame, text="Lon Max:").grid(row=1, column=3)
ttk.Entry(params_frame, textvariable=self.lon2_var, width=10).grid(row=1, column=4)
self.duration_var = tk.StringVar(value="3")
ttk.Label(params_frame, text="Days").grid(row=0, column=6)
ttk.Combobox(params_frame, textvariable=self.duration_var, values=["1", "2", "3", "4", "5", "6", "7", "8", "9"], width=5).grid(row=1, column=6)
btn_frame = ttk.Frame(self.frame_download)
btn_frame.pack(pady=5)
ttk.Button(btn_frame, text="Download", command=self.start_download).grid(row=0, column=1, padx=5)
ttk.Button(btn_frame, text="Open Folder", command=self.open_download_folder).grid(row=0, column=2, padx=5)
self.status = ttk.Label(self.frame_download, text="")
self.status.pack()
self.progress = ttk.Progressbar(self.frame_download, orient="horizontal", length=300, mode="indeterminate")
self.progress.pack(pady=5)
self.display_image()
def on_canvas_configure(self, event):
if not self.initialized:
self.canvas_width = event.width
self.canvas_height = event.height
#print(f"configure canvas {self.original_img.width}")
factor = self.canvas_width / self.original_img.width
self.zoom(factor,0,-round(self.canvas_height / 2)) # Affichage initial sans zoom
self.initialized = True
def start_pan(self, event):
self.start_pan_x = event.x
self.start_pan_y = event.y
def pan_image(self, event):
dx = event.x - self.start_pan_x
dy = event.y - self.start_pan_y
self.offset_x += dx
self.offset_y += dy
self.canvas.move(self.img_id, dx, dy)
self.start_pan_x = event.x
self.start_pan_y = event.y
self.redraw_rectangle_from_coords()
def setzoom(self, event):
# Define the zoom direction
if event.num == 5 or event.delta == -120:
zoom_factor = 0.9
elif event.num == 4 or event.delta == 120:
zoom_factor = 1.1
else:
return
self.zoom(zoom_factor,event.x,event.y)
def zoom(self, zoom_factor, zoom_x=200, zoom_y=200):
# Calculer le nouveau facteur de zoom
new_scale = self.scale * zoom_factor
if self.min_scale <= new_scale <= self.max_scale:
self.scale = new_scale
# Redimensionner l'image
width = int(self.original_img.width * self.scale)
height = int(self.original_img.height * self.scale)
self.display_img = self.original_img.resize((width, height), Image.Resampling.LANCZOS)
self.tk_img = ImageTk.PhotoImage(self.display_img)
# Mettre à jour l'image sur le canvas
self.canvas.itemconfig(self.img_id, image=self.tk_img)
# Ajuster la position de l'image pour centrer le zoom sur le pointeur de la souris
canvas_coords = self.canvas.coords(self.img_id)
mouse_x = self.canvas.canvasx(zoom_x)
mouse_y = self.canvas.canvasy(zoom_y)
self.offset_x = mouse_x - (mouse_x - canvas_coords[0]) * zoom_factor
self.offset_y = mouse_y - (mouse_y - canvas_coords[1]) * zoom_factor
self.canvas.coords(self.img_id, self.offset_x, self.offset_y)
self.redraw_rectangle_from_coords()
def open_link(self, event=None):
webbrowser.open_new("https://data.marine.copernicus.eu/register")
def setup_config_tab(self):
frame = self.frame_config
ttk.Label(frame, text="Use the credential of your account ").pack(pady=5)
account_link = tk.Label(frame, text="register on https://data.marine.copernicus.eu/register",fg="blue",cursor="hand2")
account_link.pack(pady=5)
account_link.bind("<Button-1>", self.open_link)
ttk.Label(frame, text="Copernicus Marine 'username'").pack(pady=5)
self.username_var = tk.StringVar(value=self.config.get("AUTH", "username", fallback=""))
ttk.Entry(frame, textvariable=self.username_var, width=30).pack()
ttk.Label(frame, text="Copernicus Marine 'password'").pack(pady=5)
self.password_var = tk.StringVar(value=self.config.get("AUTH", "password", fallback=""))
ttk.Entry(frame, textvariable=self.password_var, show="*", width=30).pack()
ttk.Button(frame, text="Sauvegarder", command=self.save_credentials).pack(pady=10)
def save_credentials(self):
self.config["AUTH"]["username"] = self.username_var.get()
self.config["AUTH"]["password"] = self.password_var.get()
self.save_config()
messagebox.showinfo("Info", "Identifiants sauvegardés avec succès.")
# Met à jour le downloader avec les nouveaux identifiants
self.downloader.username = self.username_var.get()
self.downloader.password = self.password_var.get()
def redraw_rectangle_from_coords(self):
try:
lat1 = float(self.lat1_var.get())
lat2 = float(self.lat2_var.get())
lon1 = float(self.lon1_var.get())
lon2 = float(self.lon2_var.get())
# Convert lat/lon to canvas pixel coordinates
x1, y1 = self.xy_from_latlon(lat1, lon1)
x2, y2 = self.xy_from_latlon(lat2, lon2)
# Supprimer l'ancien rectangle s'il existe
if self.rect:
self.canvas.delete(self.rect)
self.rect = self.canvas.create_rectangle(x1, y1, x2, y2, outline="red", width=2)
except ValueError:
# Ignore si les champs ne sont pas valides
pass
def xy_from_latlon(self, lat, lon):
lat_min = self.config.getfloat('map', 'map_lat_start')
lat_max = self.config.getfloat('map', 'map_lat_end')
lon_min = self.config.getfloat('map', 'map_lon_start')
lon_max = self.config.getfloat('map', 'map_lon_end')
canvas_w = self.canvas.winfo_width()
canvas_h = self.canvas.winfo_height()
# print(f"canvas = {canvas_w} {canvas_h} scale = {self.scale} offset = {self.offset_x} {self.offset_y}")
# Longitude (linéaire)
x_norm = (lon - lon_min) / (lon_max - lon_min)
x = x_norm * self.scale * self.original_img.width + self.offset_x
# Latitude (projection Mercator)
mercator_min = log(tan(pi / 4 + radians(lat_min) / 2)) # Mercator
mercator_max = log(tan(pi / 4 + radians(lat_max) / 2)) # Mercator
mercator_lat = log(tan(pi / 4 + radians(lat) / 2)) # Mercator
y_norm = (mercator_max - mercator_lat) / (mercator_max - mercator_min)
y = y_norm * self.scale * self.original_img.height + self.offset_y
return x, y
def latlon_from_xy(self, x, y):
# Coordonnées géographiques de la carte
lat_min = self.config.getfloat('map', 'map_lat_start')
lat_max = self.config.getfloat('map', 'map_lat_end')
lon_min = self.config.getfloat('map', 'map_lon_start')
lon_max = self.config.getfloat('map', 'map_lon_end')
# print(f"latlon_from_xy dbg coord = {lat_min}-{lat_max}-{lon_min}-{lon_max}")
# Calculer la position relative dans limage (sans offset/zoom)
img_x = (x - self.offset_x) / self.scale
img_y = (y - self.offset_y) / self.scale
if 0 <= img_x <= self.original_img.width and 0 <= img_y <= self.original_img.height:
rel_x = img_x / self.original_img.width
rel_y = img_y / self.original_img.height
# Longitude : linéaire
lon = lon_min + rel_x * (lon_max - lon_min)
# Latitude : projection inverse de Mercator
merc_min = log(tan(pi / 4 + radians(lat_min) / 2)) # Mercator
merc_max = log(tan(pi / 4 + radians(lat_max) / 2)) # Mercator
merc_y = merc_max - rel_y * (merc_max - merc_min)
lat = degrees(atan(sinh(merc_y))) # Mercator Inverse
return lat, lon
else:
return None, None # En dehors de l'image
def on_click(self, event):
self.start_x, self.start_y = event.x, event.y
if self.rect:
self.canvas.delete(self.rect)
self.rect = self.canvas.create_rectangle(self.start_x, self.start_y, event.x, event.y, outline="red")
def on_drag(self, event):
if self.rect:
self.canvas.coords(self.rect, self.start_x, self.start_y, event.x, event.y)
def on_release(self, event):
lat1, lon1 = self.latlon_from_xy(self.start_x, self.start_y)
lat2, lon2 = self.latlon_from_xy(event.x, event.y)
self.lat1_var.set(f"{min(lat1, lat2):.4f}")
self.lat2_var.set(f"{max(lat1, lat2):.4f}")
self.lon1_var.set(f"{min(lon1, lon2):.4f}")
self.lon2_var.set(f"{max(lon1, lon2):.4f}")
def display_image(self):
self.canvas_width = self.canvas.winfo_width() or self.original_img.width
self.canvas_height = self.canvas.winfo_height() or self.original_img.height
resized_img = self.original_img.resize((self.canvas_width, self.canvas_height), Image.Resampling.LANCZOS)
self.tk_img = ImageTk.PhotoImage(resized_img)
self.canvas.itemconfig(self.img_id, image=self.tk_img)
def start_download(self):
try:
lat_min = float(self.lat1_var.get())
lat_max = float(self.lat2_var.get())
lon_min = float(self.lon1_var.get())
lon_max = float(self.lon2_var.get())
days = int(self.duration_var.get())
except ValueError:
messagebox.showerror("❌ Erreur", "Veuillez entrer des coordonnées et durée valides.")
return
self.progress.start(10)
self.status.config(text="✅ Téléchargement en cours...")
threading.Thread(target=self.download, args=(lat_min, lat_max, lon_min, lon_max, days), daemon=True).start()
def download(self, lat_min, lat_max, lon_min, lon_max, days):
try:
grib_path = self.downloader.retrieve_grib2(lat_min, lat_max, lon_min, lon_max, days)
#self.status.config(text="Conversion en GRIB2 en cours...")
#grib_path = self.downloader.convert_to_grib2()
self.status.config(text=f"✅ Téléchargement et conversion terminés : {os.path.basename(grib_path)}")
except Exception as e:
self.status.config(text=f"❌ Erreur : {e}")
finally:
self.progress.stop()
def open_download_folder(self):
folder_path = os.path.join(os.getcwd(), "downloads")
if not os.path.exists(folder_path):
os.makedirs(folder_path)
if platform.system() == "Windows":
os.startfile(folder_path)
elif platform.system() == "Darwin": # macOS
subprocess.run(["open", folder_path])
else: # Linux
subprocess.run(["xdg-open", folder_path])
if __name__ == "__main__":
root = tk.Tk()
root.geometry("900x600")
app = MapSelector(root)
root.mainloop()

BIN
map-N56-W12-N44-E10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

BIN
map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 KiB

BIN
map_N44_N56_W12_E12.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 MiB

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
pillow
copernicusmarine