249 lines
6.9 KiB
Python
249 lines
6.9 KiB
Python
import math
|
|
import cairo
|
|
|
|
from xmet.igra import IGRAReader
|
|
from xmet.sounding import Sounding
|
|
|
|
WIND_SPEED_MAX = 140 # knots
|
|
WIND_SPEED_MIN = 10
|
|
WIND_SPEED_STEP = 10
|
|
|
|
WIND_DIR_STEP = 90 # degrees
|
|
|
|
def radians(degrees: float) -> float:
|
|
return (degrees + 90) * (math.pi / 180.0)
|
|
|
|
def degrees(radians: float) -> float:
|
|
return (radians * (180.0 / math.pi)) - 90
|
|
|
|
def knots(ms: float) -> float:
|
|
return ms * 1.944
|
|
|
|
class Hodograph():
|
|
def __init__(self, width, height):
|
|
self.zoom = 1.2
|
|
self.offset_x = 0.1 # Percentage of the upper left quadrant rendered
|
|
self.offset_y = 0.9 # Percentage of the upper right quadrant rendered
|
|
|
|
self.min_x = 0
|
|
self.min_y = 0
|
|
self.max_x = 0
|
|
self.max_y = 0
|
|
|
|
self.width = min(width, height)
|
|
self.height = min(width, height)
|
|
self.radius = min(width, height) * self.zoom
|
|
|
|
def sample_to_graph(self, wind_speed: dir, wind_dir: float) -> tuple:
|
|
r = self.radius * (wind_speed / WIND_SPEED_MAX)
|
|
|
|
x = r * math.cos(radians(wind_dir))
|
|
y = r * math.sin(radians(wind_dir))
|
|
|
|
return x, y
|
|
|
|
def sample_to_screen(self, wind_speed: dir, wind_dir: float) -> tuple:
|
|
gx, gy = self.sample_to_graph(wind_speed, wind_dir)
|
|
|
|
return (
|
|
self.offset_x * self.width + gx,
|
|
self.offset_y * self.height + gy
|
|
)
|
|
|
|
def find_extents(self, sounding: Sounding):
|
|
"""
|
|
Determine the boundaries of the sounding to set the center and zoom
|
|
appropriately.
|
|
"""
|
|
for sample in sounding.samples:
|
|
x, y = self.sample_to_graph(sample.wind_speed, sample.wind_dir)
|
|
|
|
if self.min_x > x:
|
|
self.min_x = x
|
|
|
|
if self.min_y > y:
|
|
self.min_y = y
|
|
|
|
if self.max_x < x:
|
|
self.max_x = x
|
|
|
|
if self.max_y < y:
|
|
self.max_y = y
|
|
|
|
self.offset_x = self.min_x / self.radius + 0.1
|
|
self.offset_y = (self.radius - self.max_y) / self.radius
|
|
self.zoom = min(self.width, self.height) / max(self.max_x - self.min_x,
|
|
self.max_y - self.min_y) / 2
|
|
self.radius = min(self.width, self.height) * self.zoom
|
|
|
|
def draw_speed_lines(self, cr: cairo.Context, x, y):
|
|
cr.save()
|
|
|
|
cr.set_source_rgb(0.5, 0.5, 0.5)
|
|
cr.set_line_width(0.5)
|
|
cr.set_dash([5, 5], 1)
|
|
|
|
for speed in range(WIND_SPEED_MIN, WIND_SPEED_MAX+1, WIND_SPEED_STEP):
|
|
cr.arc(x + self.offset_x * self.width,
|
|
y + self.offset_y * self.height,
|
|
self.radius * min(speed, WIND_SPEED_MAX) / WIND_SPEED_MAX,
|
|
0,
|
|
2*math.pi)
|
|
|
|
cr.stroke()
|
|
|
|
cr.restore()
|
|
|
|
def draw_direction_lines(self, cr: cairo.Context, x, y):
|
|
cr.save()
|
|
|
|
cr.set_source_rgb(0.5, 0.5, 0.5)
|
|
|
|
for angle in range(0, 360+1, WIND_DIR_STEP):
|
|
sx1, sy1 = self.sample_to_screen(WIND_SPEED_MAX, angle)
|
|
sx2, sy2 = self.sample_to_screen(WIND_SPEED_MAX, angle + 180)
|
|
|
|
cr.move_to(x + sx1, y + sy1)
|
|
cr.line_to(x + sx2, y + sy2)
|
|
cr.stroke()
|
|
|
|
cr.restore()
|
|
|
|
def draw_speed_legends(self, cr: cairo.Context, x, y):
|
|
cr.save()
|
|
|
|
x_offset_scale = 0.01190476
|
|
y_offset_scale = 0.01
|
|
|
|
x_offset = x_offset_scale * self.radius
|
|
y_offset = y_offset_scale * self.radius
|
|
|
|
for speed in range(WIND_SPEED_MIN, WIND_SPEED_MAX, WIND_SPEED_STEP):
|
|
text = "%dkt" % speed
|
|
extents = cr.text_extents(text)
|
|
|
|
sx, sy = self.sample_to_screen(speed, 180)
|
|
|
|
cr.move_to(x + sx + x_offset, y + sy - y_offset)
|
|
cr.show_text(text)
|
|
cr.stroke()
|
|
|
|
cr.restore()
|
|
|
|
COLORS = {
|
|
500: (1.0, 0.4, 1.0),
|
|
3000: (1.0, 0.4, 0.4),
|
|
7000: (0.4, 1.0, 0.4),
|
|
10000: (0.4, 0.4, 1.0)
|
|
}
|
|
|
|
def color(self, height: float):
|
|
for key in self.COLORS:
|
|
if height < key:
|
|
return self.COLORS[key]
|
|
|
|
def draw_sounding(self,
|
|
cr: cairo.Context,
|
|
x,
|
|
y,
|
|
sounding: Sounding):
|
|
cr.save()
|
|
|
|
first = True
|
|
color_last = None
|
|
sx_last = None
|
|
sy_last = None
|
|
|
|
for sample in sounding.samples:
|
|
if sample.pressure < 0 or sample.pressure is None:
|
|
continue
|
|
|
|
if sample.height is None:
|
|
continue
|
|
|
|
color = self.color(sample.height)
|
|
|
|
if color is None:
|
|
break
|
|
elif color is not color_last:
|
|
cr.stroke()
|
|
cr.set_source_rgb(*color)
|
|
|
|
if sx_last is not None and sy_last is not None:
|
|
cr.move_to(x + sx_last, x + sy_last)
|
|
|
|
sx, sy = self.sample_to_screen(knots(sample.wind_speed),
|
|
sample.wind_dir)
|
|
|
|
if first:
|
|
cr.move_to(x + sx, y + sy)
|
|
first = False
|
|
else:
|
|
cr.line_to(x + sx, y + sy)
|
|
|
|
color_last = color
|
|
sx_last = sx
|
|
sy_last = sy
|
|
|
|
cr.stroke()
|
|
cr.restore()
|
|
|
|
def draw_height_legends(self, cr: cairo.Context, x, y):
|
|
width = 0.02380952 * self.width
|
|
interval = 0.05952380 * self.width
|
|
offset = 0.75 * self.width
|
|
|
|
cr.save()
|
|
cr.set_source_rgb(1, 1, 1)
|
|
|
|
cr.rectangle(offset + x - width * 0.75,
|
|
y + width,
|
|
interval * 4,
|
|
width * 3)
|
|
|
|
cr.fill()
|
|
|
|
cr.set_source_rgb(0.8, 0.8, 0.8)
|
|
cr.rectangle(offset + x - width * 0.75,
|
|
y + width,
|
|
interval * 4,
|
|
width * 3)
|
|
cr.stroke()
|
|
cr.restore()
|
|
|
|
for height in self.COLORS:
|
|
color = self.COLORS[height]
|
|
|
|
cr.save()
|
|
cr.set_source_rgb(*color)
|
|
cr.rectangle(offset + x, y + width * 1.5, width, width)
|
|
cr.fill()
|
|
cr.restore()
|
|
|
|
cr.rectangle(offset + x, y + width * 1.5, width, width)
|
|
cr.stroke()
|
|
|
|
text = "%dm" % (height) if height < 1000 else "%dkm" % (height /1000)
|
|
extents = cr.text_extents(text)
|
|
|
|
cr.move_to(offset + x - extents.width / 4, y + 3.5*width)
|
|
cr.show_text(text)
|
|
cr.stroke()
|
|
|
|
offset += interval
|
|
|
|
def draw(self, cr: cairo.Context, x, y, sounding: Sounding):
|
|
self.find_extents(sounding)
|
|
|
|
cr.rectangle(x, y, self.width, self.height)
|
|
cr.clip()
|
|
|
|
self.draw_speed_lines(cr, x, y)
|
|
self.draw_direction_lines(cr, x, y)
|
|
|
|
self.draw_sounding(cr, x, y, sounding)
|
|
|
|
self.draw_speed_legends(cr, x, y)
|
|
self.draw_height_legends(cr, x, y)
|
|
|
|
cr.reset_clip()
|