Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,28 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCo
@NotNull(message = "P-Value Column cannot be empty")
var pvalueColumn: EncodableString = ""

@JsonProperty(required = false)
@JsonSchemaTitle("Title")
@JsonPropertyDescription("The figure title; leave empty for Volcano Plot")
var title: EncodableString = ""

@JsonProperty(required = false)
@JsonSchemaTitle("X Label")
@JsonPropertyDescription("The label for the x axis; leave empty for the effect size column name")
var xLabel: EncodableString = ""

@JsonProperty(required = false)
@JsonSchemaTitle("Y Label")
@JsonPropertyDescription("The label for the y axis; leave empty for -log10(<p-value column>)")
var yLabel: EncodableString = ""

@JsonProperty(required = false)
@JsonSchemaTitle("Color Bar Label")
@JsonPropertyDescription(
"The label for the color bar; leave empty for the effect size column name"
)
var colorLabel: EncodableString = ""

override def operatorInfo: OperatorInfo =
OperatorInfo.forVisualization(
userFriendlyName = "Volcano Plot",
Expand All @@ -84,6 +106,18 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCo
Map(operatorInfo.outputPorts.head.id -> outputSchema)
}

/** The y column is named after the selected p-value column, so a figure drawn
* from an adjusted column says so instead of always reading "-log10(pvalue)".
*/
private def yColumn: EncodableString = s"-log10($pvalueColumn)"

// An empty or blank label keeps the text the figure derives from the selected columns.
private def titleText: EncodableString = if (title.trim.nonEmpty) title else "Volcano Plot"
private def xAxisTitle: EncodableString = if (xLabel.trim.nonEmpty) xLabel else effectColumn
private def yAxisTitle: EncodableString = if (yLabel.trim.nonEmpty) yLabel else yColumn
private def colorBarTitle: EncodableString =
if (colorLabel.trim.nonEmpty) colorLabel else effectColumn

override def generatePythonCode(): String = {
pyb"""
|from pytexera import *
Expand Down Expand Up @@ -112,16 +146,21 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCo
| yield {"html-content": self.render_error("No rows with valid p-values.")}
| return
|
| table["-log10(pvalue)"] = -np.log10(table[$pvalueColumn])
| table[$yColumn] = -np.log10(table[$pvalueColumn])
|
| fig = px.scatter(
| table,
| x=$effectColumn,
| y="-log10(pvalue)",
| y=$yColumn,
| hover_name=table.columns[0],
| color=$effectColumn,
| color_continuous_scale="RdBu",
| title="Volcano Plot"
| title=$titleText
| )
| fig.update_layout(
| xaxis_title=$xAxisTitle,
| yaxis_title=$yAxisTitle,
| coloraxis_colorbar_title_text=$colorBarTitle
| )
|
| html = plotly.io.to_html(fig, include_plotlyjs='cdn', auto_play=False)
Expand All @@ -131,9 +170,12 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCo

override def producesDataFrame(): Boolean = false

// The exported script draws the same figure as the engine, so it labels it the same
// way; the only difference is that the text is a Python literal rather than decoded.
override def generateStandaloneCode(): String = {
val pvalueLit = pyStringLiteral(pvalueColumn)
val effectLit = pyStringLiteral(effectColumn)
val yLit = pyStringLiteral(yColumn)
s"""import numpy as np
|
|def render_error(msg):
Expand All @@ -151,15 +193,20 @@ class VolcanoPlotOpDesc extends PythonOperatorDescriptor with PlotlyStandaloneCo
| with open(outputHtml, "w", encoding="utf-8") as output:
| output.write(render_error("No rows with valid p-values."))
| else:
| table["-log10(pvalue)"] = -np.log10(table[$pvalueLit])
| table[$yLit] = -np.log10(table[$pvalueLit])
| fig = px.scatter(
| table,
| x=$effectLit,
| y="-log10(pvalue)",
| y=$yLit,
| hover_name=table.columns[0],
| color=$effectLit,
| color_continuous_scale="RdBu",
| title="Volcano Plot"
| title=${pyStringLiteral(titleText)}
| )
| fig.update_layout(
| xaxis_title=${pyStringLiteral(xAxisTitle)},
| yaxis_title=${pyStringLiteral(yAxisTitle)},
| coloraxis_colorbar_title_text=${pyStringLiteral(colorBarTitle)}
| )
| fig.write_json(outputJson)
| fig.write_html(outputHtml)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,15 @@
package org.apache.texera.amber.operator.visualization.volcanoPlot

import org.apache.texera.amber.core.tuple.AttributeType
import org.apache.texera.amber.operator.LogicalOp
import org.apache.texera.amber.operator.metadata.OperatorGroupConstants
import org.apache.texera.amber.util.JSONUtils.objectMapper
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

import java.nio.charset.StandardCharsets
import java.util.Base64

class VolcanoPlotOpDescSpec extends AnyFlatSpec with Matchers {

private def configured: VolcanoPlotOpDesc = {
Expand All @@ -33,6 +38,14 @@ class VolcanoPlotOpDescSpec extends AnyFlatSpec with Matchers {
op
}

private def b64(s: String): String =
Base64.getEncoder.encodeToString(s.getBytes(StandardCharsets.UTF_8))

// Every UI-provided string reaches the generated code as this runtime
// decode expression, never as a literal.
private def decoded(s: String): String =
s"self.decode_python_template('${b64(s)}')"

"VolcanoPlotOpDesc.operatorInfo" should "advertise the user-friendly name and Scientific group" in {
val info = (new VolcanoPlotOpDesc).operatorInfo
info.userFriendlyName shouldBe "Volcano Plot"
Expand Down Expand Up @@ -63,11 +76,186 @@ class VolcanoPlotOpDescSpec extends AnyFlatSpec with Matchers {
val code = configured.generatePythonCode()
code should include("class ProcessTableOperator(UDFTableOperator)")
code should include("plotly.express")
code should include("-log10(pvalue)")
val decodeOccurrences = "decode_python_template".r.findAllIn(code).length
decodeOccurrences should be >= 2
}

it should "name the y column and its axis after the selected p-value column" in {
// The y axis used to read "-log10(pvalue)" whatever column was chosen; a
// figure drawn from an adjusted column must say so.
val op = configured
op.pvalueColumn = "padj"
val code = op.generatePythonCode()
code should include(s"table[${decoded("-log10(padj)")}] = -np.log10(")
code should include(s"y=${decoded("-log10(padj)")}")
code should include(s"yaxis_title=${decoded("-log10(padj)")}")
code should not include "-log10(pvalue)"
code should not include b64("-log10(pvalue)")
}

it should "fall back to the column-derived labels when no label is set" in {
val code = configured.generatePythonCode()
code should include(s"title=${decoded("Volcano Plot")}")
code should include(s"xaxis_title=${decoded("log2fc")}")
code should include(s"yaxis_title=${decoded("-log10(pvalue)")}")
code should include(s"coloraxis_colorbar_title_text=${decoded("log2fc")}")
}

it should "route every configured label through the decoder instead of the default" in {
// Sentinels with quotes, a backslash and non-ASCII text: as decode sites
// they cannot break the Python source, and the raw text must never
// appear in it.
val op = configured
op.title = "TITLE_SENT \"quoted\" 火山"
op.xLabel = "X_LBL_SENT log₂ fold change"
op.yLabel = "Y_LBL_SENT -log₁₀(BH-adjusted p)"
op.colorLabel = "COLOR_LBL_SENT \\ back\\slash"
val code = op.generatePythonCode()
code should include(s"title=${decoded(op.title)}")
code should include(s"xaxis_title=${decoded(op.xLabel)}")
code should include(s"yaxis_title=${decoded(op.yLabel)}")
code should include(s"coloraxis_colorbar_title_text=${decoded(op.colorLabel)}")
Seq(op.title, op.xLabel, op.yLabel, op.colorLabel).foreach { sentinel =>
code should not include sentinel
}
// A set label replaces the default rather than being emitted next to it.
code should not include b64("Volcano Plot")
code should not include s"xaxis_title=${decoded("log2fc")}"
code should not include s"yaxis_title=${decoded("-log10(pvalue)")}"
code should not include s"coloraxis_colorbar_title_text=${decoded("log2fc")}"
// The y column itself keeps the derived name: a label is text on the axis,
// not a column rename.
code should include(s"y=${decoded("-log10(pvalue)")}")
}

it should "treat a whitespace-only label as empty and keep the default" in {
val op = configured
op.title = " "
op.xLabel = "\t"
op.yLabel = " "
op.colorLabel = " \n "
val code = op.generatePythonCode()
code should include(s"title=${decoded("Volcano Plot")}")
code should include(s"xaxis_title=${decoded("log2fc")}")
code should include(s"yaxis_title=${decoded("-log10(pvalue)")}")
code should include(s"coloraxis_colorbar_title_text=${decoded("log2fc")}")
Seq(op.title, op.xLabel, op.yLabel, op.colorLabel).foreach { blank =>
code should not include decoded(blank)
}
}

it should "treat each label independently so one set label leaves the others at their default" in {
val op = configured
op.yLabel = "Y_ONLY_SENT"
val code = op.generatePythonCode()
code should include(s"yaxis_title=${decoded("Y_ONLY_SENT")}")
code should include(s"title=${decoded("Volcano Plot")}")
code should include(s"xaxis_title=${decoded("log2fc")}")
code should include(s"coloraxis_colorbar_title_text=${decoded("log2fc")}")
}

it should "render a workflow saved before this change exactly as a default-constructed operator" in {
val saved = objectMapper
.readValue(
"""{"operatorType":"VolcanoPlot","effectColumn":"log2fc","pvalueColumn":"pvalue"}""",
classOf[LogicalOp]
)
.asInstanceOf[VolcanoPlotOpDesc]
val code = saved.generatePythonCode()
code shouldBe configured.generatePythonCode()
code should include(s"title=${decoded("Volcano Plot")}")
code should include(s"xaxis_title=${decoded("log2fc")}")
code should include(s"yaxis_title=${decoded("-log10(pvalue)")}")
code should include(s"coloraxis_colorbar_title_text=${decoded("log2fc")}")
}

"VolcanoPlotOpDesc.generateStandaloneCode" should "name the y column and its axis after the selected p-value column" in {
val op = configured
op.pvalueColumn = "padj"
val code = op.generateStandaloneCode()
code should include("""table["-log10(padj)"] = -np.log10(table["padj"])""")
code should include("""y="-log10(padj)",""")
code should include("""yaxis_title="-log10(padj)",""")
code should not include "-log10(pvalue)"
// The derived y column is the one literal built from a column name rather than
// taken from a field, so a quote in that name must survive the wrap and the escape.
op.pvalueColumn = "p\"adj"
val quoted = op.generateStandaloneCode()
quoted should include("table[\"-log10(p\\\"adj)\"] = -np.log10(table[\"p\\\"adj\"])")
quoted should include("yaxis_title=\"-log10(p\\\"adj)\",")
}

it should "fall back to the column-derived labels when no label is set" in {
val code = configured.generateStandaloneCode()
code should include("""title="Volcano Plot"""")
code should include("""xaxis_title="log2fc",""")
code should include("""yaxis_title="-log10(pvalue)",""")
code should include("""coloraxis_colorbar_title_text="log2fc"""")
}

it should "emit every configured label as an escaped Python literal" in {
// The export has no decoder, so the text itself is in the script: a quote or
// a backslash must come out escaped, or the label would end the literal early.
val op = configured
op.title = "TITLE_SENT \"quoted\" 火山"
op.xLabel = "X_LBL_SENT log₂ fold change"
op.yLabel = "Y_LBL_SENT -log₁₀(BH-adjusted p)"
op.colorLabel = "COLOR_LBL_SENT \\ back\\slash"
val code = op.generateStandaloneCode()
code should include("title=\"TITLE_SENT \\\"quoted\\\" 火山\"")
code should include("xaxis_title=\"X_LBL_SENT log₂ fold change\",")
code should include("yaxis_title=\"Y_LBL_SENT -log₁₀(BH-adjusted p)\",")
code should include("coloraxis_colorbar_title_text=\"COLOR_LBL_SENT \\\\ back\\\\slash\"")
// A set label replaces the default rather than being emitted next to it.
code should not include """title="Volcano Plot""""
code should not include """xaxis_title="log2fc""""
code should not include """yaxis_title="-log10(pvalue)""""
code should not include """coloraxis_colorbar_title_text="log2fc""""
// The y column itself keeps the derived name: a label is text on the axis,
// not a column rename.
code should include("""y="-log10(pvalue)",""")
}

it should "treat a whitespace-only label as empty and keep the default" in {
val op = configured
op.title = " "
op.xLabel = "\t"
op.yLabel = " "
op.colorLabel = " \n "
op.generateStandaloneCode() shouldBe configured.generateStandaloneCode()
}

it should "render a workflow saved before this change exactly as a default-constructed operator" in {
val saved = objectMapper
.readValue(
"""{"operatorType":"VolcanoPlot","effectColumn":"log2fc","pvalueColumn":"pvalue"}""",
classOf[LogicalOp]
)
.asInstanceOf[VolcanoPlotOpDesc]
saved.generateStandaloneCode() shouldBe configured.generateStandaloneCode()
}

it should "label the exported figure as the engine labels its own" in {
// Both paths derive the same texts; one decodes them at run time and the
// other writes them as literals. A label that reaches only one of them would
// make the exported figure differ from the one shown in the workflow.
val op = configured
op.pvalueColumn = "padj"
op.title = "Treated vs control"
val engine = op.generatePythonCode()
val export = op.generateStandaloneCode()
Seq(
"title" -> "Treated vs control",
"xaxis_title" -> "log2fc",
"yaxis_title" -> "-log10(padj)",
"coloraxis_colorbar_title_text" -> "log2fc"
).foreach {
case (key, text) =>
engine should include(s"$key=${decoded(text)}")
export should include(s"""$key="$text"""")
}
}

it should "currently render code even when required fields are empty (no assert guard)" in {
// Documents the present behavior: VolcanoPlotOpDesc does not assert on
// its required fields inside `generatePythonCode`. An empty
Expand Down
Loading