xmet/lib/xmet/hodograph.py

316 lines
8.4 KiB
Python
Raw Normal View History

2025-02-26 14:18:42 -05:00
import math
import cairo
from xmet.igra import IGRAReader
from xmet.sounding import Sounding
2025-02-26 14:18:42 -05:00
WIND_SPEED_MAX = 140 # knots
WIND_SPEED_MIN = 10
WIND_SPEED_STEP = 10
2025-02-26 14:18:42 -05:00
2025-03-03 17:51:04 -05:00
WIND_DIR_STEP = 30 # degrees
2025-02-26 14:18:42 -05:00
def radians(degrees: float) -> float:
return (degrees + 90) * (math.pi / 180.0)
def degrees(radians: float) -> float:
return (radians * (180.0 / math.pi)) - 90
2025-02-26 18:25:22 -05:00
def knots(ms: float) -> float:
return ms * 1.944
2025-02-26 14:18:42 -05:00
class Hodograph():
def __init__(self, width, height):
self.width = min(width, height)
self.height = min(width, height)
self.radius = min(width, height) / 2
self.extents = None
2025-02-26 14:18:42 -05:00
def sample_to_screen(self, wind_speed: dir, wind_dir: float) -> tuple:
r = self.radius * (wind_speed / WIND_SPEED_MAX)
x = self.radius + r * math.cos(radians(wind_dir))
y = self.radius + r * math.sin(radians(wind_dir))
if self.extents is None:
return x, y
else:
min_x, min_y = self.extents['min']
max_x, max_y = self.extents['max']
box_width = max_x - min_x
box_height = max_y - min_y
2025-03-03 17:20:29 -05:00
box = max(box_width, box_height) * 1.5
2025-03-03 17:20:29 -05:00
#
# Ensure the data points are centered within the hodograph
# viewbox.
#
offset_x = self.width * ((box - box_width) / box) / 2
offset_y = self.height * ((box - box_height) / box) / 2
return (
offset_x + ((x - min_x) / box) * self.width,
offset_y + ((y - min_y) / box) * self.height
)
2025-02-26 14:18:42 -05:00
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)
cx, cy = self.sample_to_screen(0, 0)
for speed in range(WIND_SPEED_MIN, 2*WIND_SPEED_MAX, WIND_SPEED_STEP):
if speed % (WIND_SPEED_STEP*2) == 0:
cr.set_dash([1, 1], 0)
else:
cr.set_dash([5, 5], 1)
sx, sy = self.sample_to_screen(speed, 0)
cr.arc(x + cx,
x + cy,
2025-03-03 17:32:19 -05:00
abs(sy - cy),
2025-02-26 14:18:42 -05:00
0,
2*math.pi)
cr.stroke()
cr.restore()
2025-02-26 14:18:42 -05:00
def draw_direction_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)
for angle in range(0, 360+1, WIND_DIR_STEP):
sx1, sy1 = self.sample_to_screen(2*WIND_SPEED_MAX, angle)
sx2, sy2 = self.sample_to_screen(2*WIND_SPEED_MAX, angle + 180)
if angle % 90 == 0:
cr.set_dash([1, 1], 0)
else:
cr.set_dash([5, 5], 1)
2025-02-26 14:18:42 -05:00
cr.move_to(x + sx1, y + sy1)
cr.line_to(x + sx2, y + sy2)
cr.stroke()
cr.restore()
2025-02-26 14:18:42 -05:00
def draw_speed_legends(self, cr: cairo.Context, x, y):
cr.save()
for speed in range(WIND_SPEED_MIN, WIND_SPEED_MAX, WIND_SPEED_STEP):
text = "%dkt" % speed
extents = cr.text_extents(text)
2025-03-03 17:50:43 -05:00
for angle in (0, 180):
sx, sy = self.sample_to_screen(speed, angle)
2025-02-26 14:18:42 -05:00
2025-03-03 17:50:43 -05:00
cr.move_to(x + sx + extents.width * 0.25,
y + sy - extents.height * 1.5)
2025-03-03 23:46:10 -05:00
2025-03-03 17:50:43 -05:00
cr.show_text(text)
2025-02-26 14:18:42 -05:00
cr.restore()
def draw_direction_legends(self, cr: cairo.Context, x, y):
cr.save()
cr.select_font_face('Sans',
cairo.FONT_SLANT_NORMAL,
cairo.FONT_WEIGHT_BOLD)
cx, cy = self.sample_to_screen(0, 0)
cr.set_font_size(14)
text = "%d°" % 180
extents = cr.text_extents(text)
cr.move_to(x + cx - extents.width / 2,
y + 4 * extents.height)
cr.show_text(text)
cr.stroke()
text = "%d°" % 0
extents = cr.text_extents(text)
cr.move_to(x + cx - extents.width / 2,
y + self.height - 4 * extents.height)
cr.show_text(text)
cr.stroke()
cr.restore()
2025-02-26 15:13:28 -05:00
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)
}
2025-02-26 14:18:42 -05:00
def color(self, height: float):
2025-02-26 15:13:28 -05:00
for key in self.COLORS:
if height < key:
return self.COLORS[key]
2025-02-26 14:18:42 -05:00
def each_significant_sample(self, sounding: Sounding):
for sample in sounding.samples:
if sample.pressure < 0 or sample.pressure is None:
continue
if sample.height is None:
continue
if self.color(sample.height) is None:
break
yield sample
def find_extents(self, sounding: Sounding):
min_x, min_y = None, None
max_x, max_y = None, None
first = True
for sample in self.each_significant_sample(sounding):
sx, sy = self.sample_to_screen(knots(sample.wind_speed),
sample.wind_dir)
if first:
min_x = sx
max_x = sx
min_y = sy
max_y = sy
first = False
else:
if min_x > sx:
min_x = sx
if min_y > sy:
min_y = sy
if max_x < sx:
max_x = sx
if max_y < sy:
max_y = sy
return {
'min': (min_x, min_y),
'max': (max_x, max_y)
}
def draw_sounding(self,
cr: cairo.Context,
x,
y,
sounding: Sounding):
2025-02-26 14:18:42 -05:00
cr.save()
first = True
color_last = None
sx_last = None
sy_last = None
min_x, min_y = self.extents['min']
max_x, max_y = self.extents['max']
2025-02-26 14:18:42 -05:00
box = max(max_x - min_x, max_y - min_y)
2025-03-03 10:43:03 -05:00
for sample in self.each_significant_sample(sounding):
2025-02-26 14:18:42 -05:00
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)
2025-02-26 18:25:22 -05:00
sx, sy = self.sample_to_screen(knots(sample.wind_speed),
sample.wind_dir)
2025-02-26 14:18:42 -05:00
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()
2025-02-26 15:13:28 -05:00
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)
2025-02-26 17:30:55 -05:00
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)
2025-02-26 17:30:55 -05:00
cr.rectangle(offset + x - width * 0.75,
y + width,
interval * 4,
width * 3)
cr.stroke()
cr.restore()
2025-02-26 15:13:28 -05:00
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)
2025-02-26 15:13:28 -05:00
cr.fill()
cr.restore()
cr.rectangle(offset + x, y + width * 1.5, width, width)
2025-02-26 15:13:28 -05:00
cr.stroke()
2025-02-26 17:30:55 -05:00
text = "%dm" % (height) if height < 1000 else "%dkm" % (height /1000)
extents = cr.text_extents(text)
2025-02-26 16:11:00 -05:00
2025-02-26 17:30:55 -05:00
cr.move_to(offset + x - extents.width / 4, y + 3.5*width)
cr.show_text(text)
2025-02-26 15:13:28 -05:00
offset += interval
def draw(self, cr: cairo.Context, x, y, sounding: Sounding):
self.extents = self.find_extents(sounding)
cr.rectangle(x, y, self.width, self.height)
cr.clip()
2025-02-26 14:18:42 -05:00
self.draw_direction_lines(cr, x, y)
self.draw_speed_lines(cr, x, y)
2025-02-26 14:18:42 -05:00
2025-03-03 17:33:33 -05:00
self.draw_sounding(cr, x, y, sounding)
2025-02-26 14:18:42 -05:00
self.draw_speed_legends(cr, x, y)
self.draw_direction_legends(cr, x, y)
2025-02-26 15:13:28 -05:00
self.draw_height_legends(cr, x, y)
cr.reset_clip()